Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aacad4536e | |||
| d96df27b4d | |||
| b5921adb36 | |||
| 7d19427d0a | |||
| 18c4cc6a9e | |||
| 2e185a41fe | |||
| 36a3d56828 | |||
| bc99b4e9d3 | |||
| a96349b89c | |||
| 2807b73f5b | |||
| b1cd556466 | |||
| 0be07ca77e | |||
| dc47742245 | |||
| 39176bfeff | |||
| 52dbd1df9e | |||
| fd1a722a4f | |||
| a2e0372cc8 | |||
| c10f2f19a6 | |||
| 0bec727f09 | |||
| 46fed70dd6 | |||
| 476773ee0c | |||
| fcee157358 | |||
| da925783a5 | |||
| 818e78104d | |||
| bf0667aac7 | |||
| a27f66bbef | |||
| 66fb0a8816 | |||
| b626a4b4ed | |||
| af21720700 | |||
| 0fff996aae | |||
| 52ef184df1 | |||
| 1ff943a2eb | |||
| b65a2f0d8c | |||
| 963c8127cf | |||
| 43bec1b460 | |||
| 01d9689772 | |||
| 77aaff878b | |||
| 2ef65b3610 | |||
| 5a364eed48 | |||
| 956f2999c2 | |||
| 9c8c3e0cc3 | |||
| 2aa32d150d |
@@ -57,6 +57,14 @@ jobs:
|
|||||||
id: coverage
|
id: coverage
|
||||||
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
|
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
|
||||||
|
|
||||||
|
- name: Upload test artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: image-references
|
||||||
|
path: bec_widgets/tests/reference_failures/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import PySide6QtAds as QtAds
|
||||||
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||||
|
if qt_platform != "offscreen":
|
||||||
|
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||||
|
|
||||||
|
# Default QtAds configuration
|
||||||
|
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||||
|
QtAds.CDockManager.setConfigFlag(
|
||||||
|
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||||
|
|||||||
@@ -106,6 +106,99 @@ class AbortButton(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 AdvancedDockArea(RPCBase):
|
||||||
|
@rpc_call
|
||||||
|
def new(
|
||||||
|
self,
|
||||||
|
widget: "BECWidget | str",
|
||||||
|
closable: "bool" = True,
|
||||||
|
floatable: "bool" = True,
|
||||||
|
movable: "bool" = True,
|
||||||
|
start_floating: "bool" = False,
|
||||||
|
where: "Literal['left', 'right', 'top', 'bottom'] | None" = None,
|
||||||
|
) -> "BECWidget":
|
||||||
|
"""
|
||||||
|
Create a new widget (or reuse an instance) and add it as a dock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
widget: Widget instance or a string widget type (factory-created).
|
||||||
|
closable: Whether the dock is closable.
|
||||||
|
floatable: Whether the dock is floatable.
|
||||||
|
movable: Whether the dock is movable.
|
||||||
|
start_floating: Start the dock in a floating state.
|
||||||
|
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
|
||||||
|
If None, uses the instance default passed at construction time.
|
||||||
|
Returns:
|
||||||
|
The widget instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def widget_map(self) -> "dict[str, QWidget]":
|
||||||
|
"""
|
||||||
|
Return a dictionary mapping widget names to their corresponding BECWidget instances.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary mapping widget names to BECWidget instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def widget_list(self) -> "list[QWidget]":
|
||||||
|
"""
|
||||||
|
Return a list of all BECWidget instances in the dock area.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of all BECWidget instances in the dock area.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@rpc_call
|
||||||
|
def lock_workspace(self) -> "bool":
|
||||||
|
"""
|
||||||
|
Get or set the lock state of the workspace.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the workspace is locked, False otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def attach_all(self):
|
||||||
|
"""
|
||||||
|
Return all floating docks to the dock area, preserving tab groups within each floating container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_call
|
||||||
|
def delete_all(self):
|
||||||
|
"""
|
||||||
|
Delete all docks and widgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@rpc_call
|
||||||
|
def mode(self) -> "str":
|
||||||
|
"""
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
|
||||||
|
@mode.setter
|
||||||
|
@rpc_call
|
||||||
|
def mode(self) -> "str":
|
||||||
|
"""
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class AutoUpdates(RPCBase):
|
class AutoUpdates(RPCBase):
|
||||||
@property
|
@property
|
||||||
@@ -442,6 +535,18 @@ class BECMainWindow(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 BECProgressBar(RPCBase):
|
class BECProgressBar(RPCBase):
|
||||||
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
|
||||||
@@ -525,6 +630,18 @@ class BECQueue(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 BECStatusBox(RPCBase):
|
class BECStatusBox(RPCBase):
|
||||||
"""An autonomous widget to display the status of BEC services."""
|
"""An autonomous widget to display the status of BEC services."""
|
||||||
@@ -541,6 +658,25 @@ class BECStatusBox(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
|
"""
|
||||||
|
Take a screenshot of the dock area and save it to a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class BaseROI(RPCBase):
|
class BaseROI(RPCBase):
|
||||||
"""Base class for all Region of Interest (ROI) implementations."""
|
"""Base class for all Region of Interest (ROI) implementations."""
|
||||||
@@ -1002,6 +1138,18 @@ class DarkModeButton(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 DeviceBrowser(RPCBase):
|
class DeviceBrowser(RPCBase):
|
||||||
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
|
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
|
||||||
@@ -1012,6 +1160,18 @@ class DeviceBrowser(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 DeviceComboBox(RPCBase):
|
class DeviceComboBox(RPCBase):
|
||||||
"""Combobox widget for device input with autocomplete for device names."""
|
"""Combobox widget for device input with autocomplete for device names."""
|
||||||
@@ -1045,6 +1205,18 @@ class DeviceInputBase(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 DeviceLineEdit(RPCBase):
|
class DeviceLineEdit(RPCBase):
|
||||||
"""Line edit widget for device input with autocomplete for device names."""
|
"""Line edit widget for device input with autocomplete for device names."""
|
||||||
@@ -1433,6 +1605,18 @@ class Heatmap(RPCBase):
|
|||||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def screenshot(self, file_name: "str | None" = None):
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
@@ -1978,6 +2162,18 @@ class Image(RPCBase):
|
|||||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def screenshot(self, file_name: "str | None" = None):
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
@@ -2590,6 +2786,25 @@ class MonacoWidget(RPCBase):
|
|||||||
str: The LSP header.
|
str: The LSP header.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
|
"""
|
||||||
|
Take a screenshot of the dock area and save it to a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class MotorMap(RPCBase):
|
class MotorMap(RPCBase):
|
||||||
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
|
||||||
@@ -2865,6 +3080,18 @@ class MotorMap(RPCBase):
|
|||||||
The font size of the legend font.
|
The font size of the legend font.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def screenshot(self, file_name: "str | None" = None):
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
@@ -3277,6 +3504,18 @@ class MultiWaveform(RPCBase):
|
|||||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def screenshot(self, file_name: "str | None" = None):
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
@@ -3498,6 +3737,18 @@ class PositionerBox(RPCBase):
|
|||||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def screenshot(self, file_name: "str | None" = None):
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
@@ -3527,6 +3778,18 @@ class PositionerBox2D(RPCBase):
|
|||||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def screenshot(self, file_name: "str | None" = None):
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
@@ -3547,6 +3810,18 @@ class PositionerControlLine(RPCBase):
|
|||||||
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
positioner (Positioner | str) : Positioner to set, accepts str or the device
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def screenshot(self, file_name: "str | None" = None):
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
@@ -3566,6 +3841,25 @@ class PositionerGroup(RPCBase):
|
|||||||
Device names must be separated by space
|
Device names must be separated by space
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
|
"""
|
||||||
|
Take a screenshot of the dock area and save it to a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class RectangularROI(RPCBase):
|
class RectangularROI(RPCBase):
|
||||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||||
@@ -3705,6 +3999,18 @@ class ResetButton(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 ResumeButton(RPCBase):
|
class ResumeButton(RPCBase):
|
||||||
"""A button that continue scan queue."""
|
"""A button that continue scan queue."""
|
||||||
@@ -3715,6 +4021,18 @@ class ResumeButton(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 Ring(RPCBase):
|
class Ring(RPCBase):
|
||||||
@rpc_call
|
@rpc_call
|
||||||
@@ -3996,6 +4314,25 @@ class RingProgressBar(RPCBase):
|
|||||||
bool: True if scan segment updates are enabled.
|
bool: True if scan segment updates are enabled.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
|
"""
|
||||||
|
Take a screenshot of the dock area and save it to a file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class SBBMonitor(RPCBase):
|
class SBBMonitor(RPCBase):
|
||||||
"""A widget to display the SBB monitor website."""
|
"""A widget to display the SBB monitor website."""
|
||||||
@@ -4007,9 +4344,15 @@ class ScanControl(RPCBase):
|
|||||||
"""Widget to submit new scans to the queue."""
|
"""Widget to submit new scans to the queue."""
|
||||||
|
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def remove(self):
|
def attach(self):
|
||||||
"""
|
"""
|
||||||
Cleanup the BECConnector
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@@ -4029,6 +4372,18 @@ class ScanProgressBar(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 ScatterCurve(RPCBase):
|
class ScatterCurve(RPCBase):
|
||||||
"""Scatter curve item for the scatter waveform widget."""
|
"""Scatter curve item for the scatter waveform widget."""
|
||||||
@@ -4327,6 +4682,18 @@ class ScatterWaveform(RPCBase):
|
|||||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
@rpc_timeout(None)
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def screenshot(self, file_name: "str | None" = None):
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
@@ -4629,6 +4996,18 @@ class StopButton(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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 TextBox(RPCBase):
|
class TextBox(RPCBase):
|
||||||
"""A widget that displays text in plain and HTML format"""
|
"""A widget that displays text in plain and HTML format"""
|
||||||
@@ -4661,6 +5040,25 @@ class VSCodeEditor(RPCBase):
|
|||||||
class Waveform(RPCBase):
|
class Waveform(RPCBase):
|
||||||
"""Widget for plotting waveforms."""
|
"""Widget for plotting waveforms."""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
|
"""
|
||||||
|
Take a screenshot of the dock area and save it to a file.
|
||||||
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def _config_dict(self) -> "dict":
|
def _config_dict(self) -> "dict":
|
||||||
@@ -4965,13 +5363,6 @@ class Waveform(RPCBase):
|
|||||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@rpc_timeout(None)
|
|
||||||
@rpc_call
|
|
||||||
def screenshot(self, file_name: "str | None" = None):
|
|
||||||
"""
|
|
||||||
Take a screenshot of the dock area and save it to a file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@rpc_call
|
@rpc_call
|
||||||
def curves(self) -> "list[Curve]":
|
def curves(self) -> "list[Curve]":
|
||||||
@@ -5213,6 +5604,18 @@ class WebConsole(RPCBase):
|
|||||||
Cleanup the BECConnector
|
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):
|
class WebsiteWidget(RPCBase):
|
||||||
"""A simple widget to display a website"""
|
"""A simple widget to display a website"""
|
||||||
@@ -5252,3 +5655,22 @@ class WebsiteWidget(RPCBase):
|
|||||||
"""
|
"""
|
||||||
Go forward in the history
|
Go forward in the history
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rpc_timeout(None)
|
||||||
|
@rpc_call
|
||||||
|
def screenshot(self, file_name: "str | None" = None):
|
||||||
|
"""
|
||||||
|
Take a screenshot of the dock area and save it to a file.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import signal
|
|||||||
import sys
|
import sys
|
||||||
from contextlib import redirect_stderr, redirect_stdout
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
|
||||||
|
import darkdetect
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from bec_lib.service_config import ServiceConfig
|
from bec_lib.service_config import ServiceConfig
|
||||||
|
from bec_qthemes import apply_theme
|
||||||
from qtmonaco.pylsp_provider import pylsp_server
|
from qtmonaco.pylsp_provider import pylsp_server
|
||||||
from qtpy.QtCore import QSize, Qt
|
from qtpy.QtCore import QSize, Qt
|
||||||
from qtpy.QtGui import QIcon
|
from qtpy.QtGui import QIcon
|
||||||
@@ -92,6 +94,11 @@ class GUIServer:
|
|||||||
Run the GUI server.
|
Run the GUI server.
|
||||||
"""
|
"""
|
||||||
self.app = QApplication(sys.argv)
|
self.app = QApplication(sys.argv)
|
||||||
|
if darkdetect.isDark():
|
||||||
|
apply_theme("dark")
|
||||||
|
else:
|
||||||
|
apply_theme("light")
|
||||||
|
|
||||||
self.app.setApplicationName("BEC")
|
self.app.setApplicationName("BEC")
|
||||||
self.app.gui_id = self.gui_id # type: ignore
|
self.app.gui_id = self.gui_id # type: ignore
|
||||||
self.setup_bec_icon()
|
self.setup_bec_icon()
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from qtpy import QtCore, QtWidgets
|
||||||
|
|
||||||
|
from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView
|
||||||
|
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||||
|
|
||||||
|
|
||||||
|
class BECMainApp(QtWidgets.QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
# Main layout
|
||||||
|
layout = QtWidgets.QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Tab widget as central area
|
||||||
|
self.tabs = QtWidgets.QTabWidget(self)
|
||||||
|
self.tabs.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.tabs.setTabPosition(QtWidgets.QTabWidget.West) # Tabs on the left side
|
||||||
|
|
||||||
|
layout.addWidget(self.tabs)
|
||||||
|
# Add DM
|
||||||
|
self._add_device_manager_view()
|
||||||
|
|
||||||
|
# Add Plot area
|
||||||
|
self._add_ad_dockarea()
|
||||||
|
|
||||||
|
# Adjust size of tab bar
|
||||||
|
# TODO not yet properly working, tabs a spread across the full length, to be checked!
|
||||||
|
tab_bar = self.tabs.tabBar()
|
||||||
|
tab_bar.setFixedWidth(tab_bar.sizeHint().width())
|
||||||
|
|
||||||
|
def _add_device_manager_view(self) -> None:
|
||||||
|
self.device_manager_view = DeviceManagerView(parent=self)
|
||||||
|
self.add_tab(self.device_manager_view, "Device Manager")
|
||||||
|
|
||||||
|
def _add_ad_dockarea(self) -> None:
|
||||||
|
self.advanced_dock_area = AdvancedDockArea(parent=self)
|
||||||
|
self.add_tab(self.advanced_dock_area, "Plot Area")
|
||||||
|
|
||||||
|
def add_tab(self, widget: QtWidgets.QWidget, title: str):
|
||||||
|
"""Add a custom QWidget as a tab."""
|
||||||
|
tab_container = QtWidgets.QWidget()
|
||||||
|
tab_layout = QtWidgets.QVBoxLayout(tab_container)
|
||||||
|
tab_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
tab_layout.setSpacing(0)
|
||||||
|
|
||||||
|
tab_layout.addWidget(widget)
|
||||||
|
self.tabs.addTab(tab_container, title)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from bec_lib.bec_yaml_loader import yaml_load
|
||||||
|
from bec_qthemes import apply_theme
|
||||||
|
|
||||||
|
app = QtWidgets.QApplication(sys.argv)
|
||||||
|
apply_theme("light")
|
||||||
|
win = BECMainApp()
|
||||||
|
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||||
|
cfg = yaml_load(config_path)
|
||||||
|
cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
|
||||||
|
win.device_manager_view.device_table_view.set_device_config(cfg)
|
||||||
|
win.resize(1920, 1080)
|
||||||
|
win.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING, List
|
||||||
|
|
||||||
|
import PySide6QtAds as QtAds
|
||||||
|
import yaml
|
||||||
|
from bec_lib.bec_yaml_loader import yaml_load
|
||||||
|
from bec_lib.file_utils import DeviceConfigWriter
|
||||||
|
from bec_lib.logger import bec_logger
|
||||||
|
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||||
|
from bec_qthemes import apply_theme
|
||||||
|
from PySide6QtAds import CDockManager, CDockWidget
|
||||||
|
from qtpy.QtCore import Qt, QTimer
|
||||||
|
from qtpy.QtWidgets import QFileDialog, QMessageBox, QSplitter, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from bec_widgets import BECWidget
|
||||||
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||||
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||||
|
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||||
|
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||||
|
from bec_widgets.widgets.control.device_manager.components import (
|
||||||
|
DeviceTableView,
|
||||||
|
DMConfigView,
|
||||||
|
DMOphydTest,
|
||||||
|
DocstringView,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from bec_lib.client import BECClient
|
||||||
|
|
||||||
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|
||||||
|
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||||
|
"""
|
||||||
|
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
|
||||||
|
Works for horizontal or vertical splitters and sets matching stretch factors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def apply():
|
||||||
|
n = splitter.count()
|
||||||
|
if n == 0:
|
||||||
|
return
|
||||||
|
w = list(weights[:n]) + [1] * max(0, n - len(weights))
|
||||||
|
w = [max(0.0, float(x)) for x in w]
|
||||||
|
tot_w = sum(w)
|
||||||
|
if tot_w <= 0:
|
||||||
|
w = [1.0] * n
|
||||||
|
tot_w = float(n)
|
||||||
|
total_px = (
|
||||||
|
splitter.width() if splitter.orientation() == Qt.Horizontal else splitter.height()
|
||||||
|
)
|
||||||
|
if total_px < 2:
|
||||||
|
QTimer.singleShot(0, apply)
|
||||||
|
return
|
||||||
|
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
|
||||||
|
diff = total_px - sum(sizes)
|
||||||
|
if diff != 0:
|
||||||
|
idx = max(range(n), key=lambda i: w[i])
|
||||||
|
sizes[idx] = max(1, sizes[idx] + diff)
|
||||||
|
splitter.setSizes(sizes)
|
||||||
|
for i, wi in enumerate(w):
|
||||||
|
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
|
||||||
|
|
||||||
|
QTimer.singleShot(0, apply)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceManagerView(BECWidget, QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None, *args, **kwargs):
|
||||||
|
super().__init__(parent=parent, client=None, *args, **kwargs)
|
||||||
|
|
||||||
|
# Top-level layout hosting a toolbar and the dock manager
|
||||||
|
self._root_layout = QVBoxLayout(self)
|
||||||
|
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._root_layout.setSpacing(0)
|
||||||
|
self.dock_manager = CDockManager(self)
|
||||||
|
self._root_layout.addWidget(self.dock_manager)
|
||||||
|
|
||||||
|
# Available Resources Widget
|
||||||
|
self.available_devices = QWidget(self)
|
||||||
|
self.available_devices_dock = QtAds.CDockWidget("Available Devices", self)
|
||||||
|
self.available_devices_dock.setWidget(self.available_devices)
|
||||||
|
|
||||||
|
# Device Table View widget
|
||||||
|
self.device_table_view = DeviceTableView(self)
|
||||||
|
self.device_table_view_dock = QtAds.CDockWidget("Device Table", self)
|
||||||
|
self.device_table_view_dock.setWidget(self.device_table_view)
|
||||||
|
|
||||||
|
# Device Config View widget
|
||||||
|
self.dm_config_view = DMConfigView(self)
|
||||||
|
self.dm_config_view_dock = QtAds.CDockWidget("Device Config View", self)
|
||||||
|
self.dm_config_view_dock.setWidget(self.dm_config_view)
|
||||||
|
|
||||||
|
# Docstring View
|
||||||
|
self.dm_docs_view = DocstringView(self)
|
||||||
|
self.dm_docs_view_dock = QtAds.CDockWidget("Docstring View", self)
|
||||||
|
self.dm_docs_view_dock.setWidget(self.dm_docs_view)
|
||||||
|
|
||||||
|
# Ophyd Test view
|
||||||
|
self.ophyd_test_view = DMOphydTest(self)
|
||||||
|
self.ophyd_test_dock_view = QtAds.CDockWidget("Ophyd Test View", self)
|
||||||
|
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
|
||||||
|
|
||||||
|
# Arrange widgets within the QtAds dock manager
|
||||||
|
|
||||||
|
# Central widget area
|
||||||
|
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
|
||||||
|
self.dock_manager.addDockWidget(
|
||||||
|
QtAds.DockWidgetArea.BottomDockWidgetArea,
|
||||||
|
self.dm_docs_view_dock,
|
||||||
|
self.central_dock_area,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Left Area
|
||||||
|
self.left_dock_area = self.dock_manager.addDockWidget(
|
||||||
|
QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock
|
||||||
|
)
|
||||||
|
self.dock_manager.addDockWidget(
|
||||||
|
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_config_view_dock, self.left_dock_area
|
||||||
|
)
|
||||||
|
|
||||||
|
# Right area
|
||||||
|
self.dock_manager.addDockWidget(
|
||||||
|
QtAds.DockWidgetArea.RightDockWidgetArea, self.ophyd_test_dock_view
|
||||||
|
)
|
||||||
|
|
||||||
|
for dock in self.dock_manager.dockWidgets():
|
||||||
|
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
|
||||||
|
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
|
||||||
|
dock.setFeature(CDockWidget.DockWidgetClosable, False)
|
||||||
|
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
|
||||||
|
dock.setFeature(CDockWidget.DockWidgetMovable, False)
|
||||||
|
|
||||||
|
# Fetch all dock areas of the dock widgets (on our case always one dock area)
|
||||||
|
for dock in self.dock_manager.dockWidgets():
|
||||||
|
area = dock.dockAreaWidget()
|
||||||
|
area.titleBar().setVisible(False)
|
||||||
|
|
||||||
|
# Apply stretch after the layout is done
|
||||||
|
self.set_default_view([2, 8, 2], [3, 1])
|
||||||
|
# self.set_default_view([2, 8, 2], [2, 2, 4])
|
||||||
|
|
||||||
|
# Connect slots
|
||||||
|
self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config)
|
||||||
|
self.device_table_view.selected_device.connect(self.dm_docs_view.on_select_config)
|
||||||
|
self.ophyd_test_view.device_validated.connect(
|
||||||
|
self.device_table_view.update_device_validation
|
||||||
|
)
|
||||||
|
self.device_table_view.device_configs_added.connect(self.ophyd_test_view.add_device_configs)
|
||||||
|
|
||||||
|
self._add_toolbar()
|
||||||
|
|
||||||
|
def _add_toolbar(self):
|
||||||
|
self.toolbar = ModularToolBar(self)
|
||||||
|
|
||||||
|
# Add IO actions
|
||||||
|
self._add_io_actions()
|
||||||
|
self._add_table_actions()
|
||||||
|
self.toolbar.show_bundles(["IO", "Table"])
|
||||||
|
self._root_layout.insertWidget(0, self.toolbar)
|
||||||
|
|
||||||
|
def _add_io_actions(self):
|
||||||
|
# Create IO bundle
|
||||||
|
io_bundle = ToolbarBundle("IO", self.toolbar.components)
|
||||||
|
|
||||||
|
# Add load config from plugin dir
|
||||||
|
self.toolbar.add_bundle(io_bundle)
|
||||||
|
|
||||||
|
load = MaterialIconAction(
|
||||||
|
icon_name="file_open", parent=self, tooltip="Load configuration file from disk"
|
||||||
|
)
|
||||||
|
self.toolbar.components.add_safe("load", load)
|
||||||
|
load.action.triggered.connect(self._load_file_action)
|
||||||
|
io_bundle.add_action("load")
|
||||||
|
|
||||||
|
# Add safe to disk
|
||||||
|
safe_to_disk = MaterialIconAction(
|
||||||
|
icon_name="file_save", parent=self, tooltip="Save config to disk"
|
||||||
|
)
|
||||||
|
self.toolbar.components.add_safe("safe_to_disk", safe_to_disk)
|
||||||
|
safe_to_disk.action.triggered.connect(self._safe_to_disk_action)
|
||||||
|
io_bundle.add_action("safe_to_disk")
|
||||||
|
|
||||||
|
# Add load config from redis
|
||||||
|
load_redis = MaterialIconAction(
|
||||||
|
icon_name="cached", parent=self, tooltip="Load current config from Redis"
|
||||||
|
)
|
||||||
|
load_redis.action.triggered.connect(self._load_redis_action)
|
||||||
|
self.toolbar.components.add_safe("load_redis", load_redis)
|
||||||
|
io_bundle.add_action("load_redis")
|
||||||
|
|
||||||
|
# Update config action
|
||||||
|
update_config_redis = MaterialIconAction(
|
||||||
|
icon_name="cloud_upload", parent=self, tooltip="Update current config in Redis"
|
||||||
|
)
|
||||||
|
update_config_redis.action.triggered.connect(self._update_redis_action)
|
||||||
|
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
|
||||||
|
io_bundle.add_action("update_config_redis")
|
||||||
|
|
||||||
|
# Table actions
|
||||||
|
|
||||||
|
def _add_table_actions(self) -> None:
|
||||||
|
table_bundle = ToolbarBundle("Table", self.toolbar.components)
|
||||||
|
|
||||||
|
# Add load config from plugin dir
|
||||||
|
self.toolbar.add_bundle(table_bundle)
|
||||||
|
|
||||||
|
# Reset composed view
|
||||||
|
reset_composed = MaterialIconAction(
|
||||||
|
icon_name="delete_sweep", parent=self, tooltip="Reset current composed config view"
|
||||||
|
)
|
||||||
|
reset_composed.action.triggered.connect(self._reset_composed_view)
|
||||||
|
self.toolbar.components.add_safe("reset_composed", reset_composed)
|
||||||
|
table_bundle.add_action("reset_composed")
|
||||||
|
|
||||||
|
# Add device
|
||||||
|
add_device = MaterialIconAction(icon_name="add", parent=self, tooltip="Add new device")
|
||||||
|
add_device.action.triggered.connect(self._add_device_action)
|
||||||
|
self.toolbar.components.add_safe("add_device", add_device)
|
||||||
|
table_bundle.add_action("add_device")
|
||||||
|
|
||||||
|
# Remove device
|
||||||
|
remove_device = MaterialIconAction(icon_name="remove", parent=self, tooltip="Remove device")
|
||||||
|
remove_device.action.triggered.connect(self._remove_device_action)
|
||||||
|
self.toolbar.components.add_safe("remove_device", remove_device)
|
||||||
|
table_bundle.add_action("remove_device")
|
||||||
|
|
||||||
|
# Rerun validation
|
||||||
|
rerun_validation = MaterialIconAction(
|
||||||
|
icon_name="checklist", parent=self, tooltip="Run device validation on selected devices"
|
||||||
|
)
|
||||||
|
rerun_validation.action.triggered.connect(self._rerun_validation_action)
|
||||||
|
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
|
||||||
|
table_bundle.add_action("rerun_validation")
|
||||||
|
|
||||||
|
# Most likly, no actions on available devices
|
||||||
|
# Actions (vielleicht bundle fuer available devices )
|
||||||
|
# - reset composed view
|
||||||
|
# - add new device (EpicsMotor, EpicsMotorECMC, EpicsSignal, CustomDevice)
|
||||||
|
# - remove device
|
||||||
|
# - rerun validation (with/without connect)
|
||||||
|
|
||||||
|
# IO actions
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def _load_file_action(self):
|
||||||
|
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
|
||||||
|
# Check if plugin repo is installed...
|
||||||
|
try:
|
||||||
|
plugin_path = plugin_repo_path()
|
||||||
|
plugin_name = plugin_package_name()
|
||||||
|
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
|
||||||
|
except ValueError:
|
||||||
|
# Get the recovery config path as fallback
|
||||||
|
config_path = self._get_recovery_config_path()
|
||||||
|
logger.warning(
|
||||||
|
f"No plugin repository installed, fallback to recovery config path: {config_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Implement the file loading logic here
|
||||||
|
start_dir = os.path.abspath(config_path)
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, caption="Select Config File", dir=start_dir
|
||||||
|
)
|
||||||
|
if file_path:
|
||||||
|
try:
|
||||||
|
config = yaml_load(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||||
|
return
|
||||||
|
self.device_table_view.set_device_config(
|
||||||
|
config
|
||||||
|
) # TODO ADD QDialog with 'replace', 'add' & 'cancel'
|
||||||
|
|
||||||
|
# TODO would we ever like to add the current config to an existing composition
|
||||||
|
@SafeSlot()
|
||||||
|
def _load_redis_action(self):
|
||||||
|
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Load currently active config",
|
||||||
|
"Do you really want to flush the current config and reload?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
cfg = {}
|
||||||
|
config_list = self.client.device_manager._get_redis_device_config()
|
||||||
|
for item in config_list:
|
||||||
|
k = item["name"]
|
||||||
|
item.pop("name")
|
||||||
|
cfg[k] = item
|
||||||
|
self.device_table_view.set_device_config(cfg)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def _safe_to_disk_action(self):
|
||||||
|
"""Action for the 'safe_to_disk' action to save the current config to disk."""
|
||||||
|
# Check if plugin repo is installed...
|
||||||
|
try:
|
||||||
|
config_path = self._get_recovery_config_path()
|
||||||
|
except ValueError:
|
||||||
|
# Get the recovery config path as fallback
|
||||||
|
config_path = os.path.abspath(os.path.expanduser("~"))
|
||||||
|
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
|
||||||
|
|
||||||
|
# Implement the file loading logic here
|
||||||
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self, caption="Save Config File", dir=config_path
|
||||||
|
)
|
||||||
|
if file_path:
|
||||||
|
config = self.device_table_view.get_device_config()
|
||||||
|
with open(file_path, "w") as file:
|
||||||
|
file.write(yaml.dump(config))
|
||||||
|
|
||||||
|
# TODO add here logic, should be asyncronous, but probably block UI, and show a loading spinner. If failed, it should report..
|
||||||
|
@SafeSlot()
|
||||||
|
def _update_redis_action(self):
|
||||||
|
"""Action for the 'update_redis' action to update the current config in Redis."""
|
||||||
|
config = self.device_table_view.get_device_config()
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Not implemented yet",
|
||||||
|
"This feature has not been implemented yet, will be coming soon...!!",
|
||||||
|
QMessageBox.Cancel,
|
||||||
|
QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Table actions
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def _reset_composed_view(self):
|
||||||
|
"""Action for the 'reset_composed_view' action to reset the composed view."""
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Clear View",
|
||||||
|
"You are about to clear the current composed config view, please confirm...",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
self.device_table_view.clear_device_configs()
|
||||||
|
|
||||||
|
# TODO Here we would like to implement a custom popup view, that allows to add new devices
|
||||||
|
# We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device
|
||||||
|
# For all default Epics devices, we would like to preselect relevant fields, and prompt them with the proper deviceConfig args already, i.e. 'prefix', 'read_pv', 'write_pv' etc..
|
||||||
|
# For custom Device, they should receive all options. It might be cool to get a side panel with docstring view of the class upon inspecting it to make it easier in case deviceConfig entries are required..
|
||||||
|
@SafeSlot()
|
||||||
|
def _add_device_action(self):
|
||||||
|
"""Action for the 'add_device' action to add a new device."""
|
||||||
|
# Implement the logic to add a new device
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Not implemented yet",
|
||||||
|
"This feature has not been implemented yet, will be coming soon...!!",
|
||||||
|
QMessageBox.Cancel,
|
||||||
|
QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO fix the device table remove actions. This is currently not working properly...
|
||||||
|
@SafeSlot()
|
||||||
|
def _remove_device_action(self):
|
||||||
|
"""Action for the 'remove_device' action to remove a device."""
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Not implemented yet",
|
||||||
|
"This feature has not been implemented yet, will be coming soon...!!",
|
||||||
|
QMessageBox.Cancel,
|
||||||
|
QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO implement proper logic for validation. We should also carefully review how these jobs update the table, and how we can cancel pending validations
|
||||||
|
# in case they are no longer relevant. We might want to 'block' the interactivity on the items for which validation runs with 'connect'!
|
||||||
|
@SafeSlot()
|
||||||
|
def _rerun_validation_action(self):
|
||||||
|
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
|
||||||
|
# Implement the logic to rerun validation on selected devices
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Not implemented yet",
|
||||||
|
"This feature has not been implemented yet, will be coming soon...!!",
|
||||||
|
QMessageBox.Cancel,
|
||||||
|
QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
####### Default view has to be done with setting up splitters ########
|
||||||
|
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
|
||||||
|
"""Apply initial weights to every horizontal and vertical splitter.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
horizontal_weights = [1, 3, 2, 1]
|
||||||
|
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||||
|
"""
|
||||||
|
splitters_h = []
|
||||||
|
splitters_v = []
|
||||||
|
for splitter in self.findChildren(QSplitter):
|
||||||
|
if splitter.orientation() == Qt.Horizontal:
|
||||||
|
splitters_h.append(splitter)
|
||||||
|
elif splitter.orientation() == Qt.Vertical:
|
||||||
|
splitters_v.append(splitter)
|
||||||
|
|
||||||
|
def apply_all():
|
||||||
|
for s in splitters_h:
|
||||||
|
set_splitter_weights(s, horizontal_weights)
|
||||||
|
for s in splitters_v:
|
||||||
|
set_splitter_weights(s, vertical_weights)
|
||||||
|
|
||||||
|
QTimer.singleShot(0, apply_all)
|
||||||
|
|
||||||
|
def set_stretch(self, *, horizontal=None, vertical=None):
|
||||||
|
"""Update splitter weights and re-apply to all splitters.
|
||||||
|
|
||||||
|
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||||
|
for convenience: horizontal roles = {"left","center","right"},
|
||||||
|
vertical roles = {"top","bottom"}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _coerce_h(x):
|
||||||
|
if x is None:
|
||||||
|
return None
|
||||||
|
if isinstance(x, (list, tuple)):
|
||||||
|
return list(map(float, x))
|
||||||
|
if isinstance(x, dict):
|
||||||
|
return [
|
||||||
|
float(x.get("left", 1)),
|
||||||
|
float(x.get("center", x.get("middle", 1))),
|
||||||
|
float(x.get("right", 1)),
|
||||||
|
]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _coerce_v(x):
|
||||||
|
if x is None:
|
||||||
|
return None
|
||||||
|
if isinstance(x, (list, tuple)):
|
||||||
|
return list(map(float, x))
|
||||||
|
if isinstance(x, dict):
|
||||||
|
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||||
|
return None
|
||||||
|
|
||||||
|
h = _coerce_h(horizontal)
|
||||||
|
v = _coerce_v(vertical)
|
||||||
|
if h is None:
|
||||||
|
h = [1, 1, 1]
|
||||||
|
if v is None:
|
||||||
|
v = [1, 1]
|
||||||
|
self.set_default_view(h, v)
|
||||||
|
|
||||||
|
def _get_recovery_config_path(self) -> str:
|
||||||
|
"""Get the recovery config path from the log_writer config."""
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
log_writer_config: BECClient = self.client._service_config.config.get("log_writer", {})
|
||||||
|
writer = DeviceConfigWriter(service_config=log_writer_config)
|
||||||
|
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from bec_lib.bec_yaml_loader import yaml_load
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
w = QWidget()
|
||||||
|
l = QVBoxLayout()
|
||||||
|
w.setLayout(l)
|
||||||
|
apply_theme("dark")
|
||||||
|
button = DarkModeButton()
|
||||||
|
l.addWidget(button)
|
||||||
|
device_manager_view = DeviceManagerView()
|
||||||
|
l.addWidget(device_manager_view)
|
||||||
|
# config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||||
|
# cfg = yaml_load(config_path)
|
||||||
|
# cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
|
||||||
|
|
||||||
|
# # config = device_manager_view.client.device_manager._get_redis_device_config()
|
||||||
|
# device_manager_view.device_table_view.set_device_config(cfg)
|
||||||
|
w.show()
|
||||||
|
w.setWindowTitle("Device Manager View")
|
||||||
|
w.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,110 @@
|
|||||||
|
"""Top Level wrapper for device_manager widget"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from bec_lib.bec_yaml_loader import yaml_load
|
||||||
|
from bec_lib.logger import bec_logger
|
||||||
|
from bec_qthemes import material_icon
|
||||||
|
from qtpy import QtCore, QtWidgets
|
||||||
|
|
||||||
|
from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView
|
||||||
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
|
|
||||||
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
|
||||||
|
|
||||||
|
def __init__(self, parent=None, client=None):
|
||||||
|
super().__init__(client=client, parent=parent)
|
||||||
|
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||||
|
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.stacked_layout.setSpacing(0)
|
||||||
|
self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
|
||||||
|
self.setLayout(self.stacked_layout)
|
||||||
|
|
||||||
|
# Add device manager view
|
||||||
|
self.device_manager_view = DeviceManagerView()
|
||||||
|
self.stacked_layout.addWidget(self.device_manager_view)
|
||||||
|
|
||||||
|
# Add overlay widget
|
||||||
|
self._overlay_widget = QtWidgets.QWidget(self)
|
||||||
|
self._customize_overlay()
|
||||||
|
self.stacked_layout.addWidget(self._overlay_widget)
|
||||||
|
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||||
|
|
||||||
|
def _customize_overlay(self):
|
||||||
|
self._overlay_widget.setStyleSheet(
|
||||||
|
"background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);"
|
||||||
|
)
|
||||||
|
self._overlay_widget.setAutoFillBackground(True)
|
||||||
|
self._overlay_layout = QtWidgets.QVBoxLayout()
|
||||||
|
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self._overlay_widget.setLayout(self._overlay_layout)
|
||||||
|
self._overlay_widget.setSizePolicy(
|
||||||
|
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
|
||||||
|
)
|
||||||
|
# Load current config
|
||||||
|
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
|
||||||
|
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
|
||||||
|
self.button_load_current_config.setIcon(icon)
|
||||||
|
self._overlay_layout.addWidget(self.button_load_current_config)
|
||||||
|
self.button_load_current_config.clicked.connect(self._load_config_clicked)
|
||||||
|
# Load config from disk
|
||||||
|
self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File")
|
||||||
|
icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False)
|
||||||
|
self.button_load_config_from_file.setIcon(icon)
|
||||||
|
self._overlay_layout.addWidget(self.button_load_config_from_file)
|
||||||
|
self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked)
|
||||||
|
self._overlay_widget.setVisible(True)
|
||||||
|
|
||||||
|
def _load_config_from_file_clicked(self):
|
||||||
|
"""Handle click on 'Load Config From File' button."""
|
||||||
|
start_dir = os.path.expanduser("~")
|
||||||
|
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
||||||
|
self, caption="Select Config File", dir=start_dir
|
||||||
|
)
|
||||||
|
if file_path:
|
||||||
|
self._load_config_from_file(file_path)
|
||||||
|
|
||||||
|
def _load_config_from_file(self, file_path: str):
|
||||||
|
try:
|
||||||
|
config = yaml_load(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||||
|
return
|
||||||
|
config_list = []
|
||||||
|
for name, cfg in config.items():
|
||||||
|
config_list.append(cfg)
|
||||||
|
config_list[-1]["name"] = name
|
||||||
|
self.device_manager_view.device_table_view.set_device_config(config_list)
|
||||||
|
# self.device_manager_view.ophyd_test.on_device_config_update(config)
|
||||||
|
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def _load_config_clicked(self):
|
||||||
|
"""Handle click on 'Load Current Config' button."""
|
||||||
|
config = self.client.device_manager._get_redis_device_config()
|
||||||
|
config.append({"name": "wrong_device", "some_value": 1})
|
||||||
|
self.device_manager_view.device_table_view.set_device_config(config)
|
||||||
|
# self.device_manager_view.ophyd_test.on_device_config_update(config)
|
||||||
|
self.stacked_layout.setCurrentWidget(self.device_manager_view)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
device_manager = DeviceManagerWidget()
|
||||||
|
# config = device_manager.client.device_manager._get_redis_device_config()
|
||||||
|
# device_manager.device_table_view.set_device_config(config)
|
||||||
|
device_manager.show()
|
||||||
|
device_manager.setWindowTitle("Device Manager View")
|
||||||
|
device_manager.resize(1600, 1200)
|
||||||
|
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||||
|
sys.exit(app.exec_())
|
||||||
@@ -15,7 +15,9 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils import BECDispatcher
|
from bec_widgets.utils import BECDispatcher
|
||||||
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
|
||||||
|
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||||
from bec_widgets.widgets.containers.dock import BECDockArea
|
from bec_widgets.widgets.containers.dock import BECDockArea
|
||||||
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
|
||||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||||
@@ -44,6 +46,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
|||||||
"wh": wh,
|
"wh": wh,
|
||||||
"dock": self.dock,
|
"dock": self.dock,
|
||||||
"im": self.im,
|
"im": self.im,
|
||||||
|
"ads": self.ads,
|
||||||
# "mi": self.mi,
|
# "mi": self.mi,
|
||||||
# "mm": self.mm,
|
# "mm": self.mm,
|
||||||
# "lm": self.lm,
|
# "lm": self.lm,
|
||||||
@@ -120,14 +123,12 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
|||||||
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||||
tab_widget.setCurrentIndex(1)
|
tab_widget.setCurrentIndex(1)
|
||||||
#
|
#
|
||||||
# seventh_tab = QWidget()
|
seventh_tab = QWidget()
|
||||||
# seventh_tab_layout = QVBoxLayout(seventh_tab)
|
seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||||
# self.scatter = ScatterWaveform()
|
self.ads = AdvancedDockArea(gui_id="ads")
|
||||||
# self.scatter_mi = self.scatter.main_curve
|
seventh_tab_layout.addWidget(self.ads)
|
||||||
# self.scatter.plot("samx", "samy", "bpm4i")
|
tab_widget.addTab(seventh_tab, "ADS")
|
||||||
# seventh_tab_layout.addWidget(self.scatter)
|
tab_widget.setCurrentIndex(2)
|
||||||
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
|
||||||
# tab_widget.setCurrentIndex(6)
|
|
||||||
#
|
#
|
||||||
# eighth_tab = QWidget()
|
# eighth_tab = QWidget()
|
||||||
# eighth_tab_layout = QVBoxLayout(eighth_tab)
|
# eighth_tab_layout = QVBoxLayout(eighth_tab)
|
||||||
@@ -169,6 +170,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
module_path = os.path.dirname(bec_widgets.__file__)
|
module_path = os.path.dirname(bec_widgets.__file__)
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
|
apply_theme("dark")
|
||||||
app.setApplicationName("Jupyter Console")
|
app.setApplicationName("Jupyter Console")
|
||||||
app.setApplicationDisplayName("Jupyter Console")
|
app.setApplicationDisplayName("Jupyter Console")
|
||||||
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class BECConnector:
|
|||||||
|
|
||||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||||
EXIT_HANDLERS = {}
|
EXIT_HANDLERS = {}
|
||||||
|
widget_removed = Signal()
|
||||||
|
name_established = Signal(str)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -204,6 +206,10 @@ class BECConnector:
|
|||||||
self._enforce_unique_sibling_name()
|
self._enforce_unique_sibling_name()
|
||||||
# 2) Register the object for RPC
|
# 2) Register the object for RPC
|
||||||
self.rpc_register.add_rpc(self)
|
self.rpc_register.add_rpc(self)
|
||||||
|
try:
|
||||||
|
self.name_established.emit(self.object_name)
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
|
||||||
def _enforce_unique_sibling_name(self):
|
def _enforce_unique_sibling_name(self):
|
||||||
"""
|
"""
|
||||||
@@ -450,6 +456,7 @@ class BECConnector:
|
|||||||
# i.e. Curve Item from Waveform
|
# i.e. Curve Item from Waveform
|
||||||
else:
|
else:
|
||||||
self.rpc_register.remove_rpc(self)
|
self.rpc_register.remove_rpc(self)
|
||||||
|
self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS)
|
||||||
|
|
||||||
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import darkdetect
|
import PySide6QtAds as QtAds
|
||||||
import shiboken6
|
import shiboken6
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from qtpy.QtCore import QObject
|
from qtpy.QtCore import QObject
|
||||||
@@ -11,9 +11,9 @@ from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
|
|||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
|
||||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||||
|
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
from bec_widgets.widgets.containers.dock import BECDock
|
from bec_widgets.widgets.containers.dock import BECDock
|
||||||
@@ -27,7 +27,7 @@ class BECWidget(BECConnector):
|
|||||||
# The icon name is the name of the icon in the icon theme, typically a name taken
|
# The icon name is the name of the icon in the icon theme, typically a name taken
|
||||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||||
ICON_NAME = "widgets"
|
ICON_NAME = "widgets"
|
||||||
USER_ACCESS = ["remove"]
|
USER_ACCESS = ["remove", "attach", "detach"]
|
||||||
|
|
||||||
# pylint: disable=too-many-arguments
|
# pylint: disable=too-many-arguments
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -45,8 +45,7 @@ class BECWidget(BECConnector):
|
|||||||
|
|
||||||
>>> class MyWidget(BECWidget, QWidget):
|
>>> class MyWidget(BECWidget, QWidget):
|
||||||
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
|
||||||
>>> super().__init__(client=client, config=config, gui_id=gui_id)
|
>>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
|
||||||
>>> QWidget.__init__(self, parent=parent)
|
|
||||||
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -62,15 +61,6 @@ class BECWidget(BECConnector):
|
|||||||
)
|
)
|
||||||
if not isinstance(self, QObject):
|
if not isinstance(self, QObject):
|
||||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||||
app = QApplication.instance()
|
|
||||||
if not hasattr(app, "theme"):
|
|
||||||
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
|
|
||||||
# Instead, we will set the theme to the system setting on startup
|
|
||||||
if darkdetect.isDark():
|
|
||||||
set_theme("dark")
|
|
||||||
else:
|
|
||||||
set_theme("light")
|
|
||||||
|
|
||||||
if theme_update:
|
if theme_update:
|
||||||
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
|
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
|
||||||
self._connect_to_theme_change()
|
self._connect_to_theme_change()
|
||||||
@@ -78,9 +68,11 @@ class BECWidget(BECConnector):
|
|||||||
def _connect_to_theme_change(self):
|
def _connect_to_theme_change(self):
|
||||||
"""Connect to the theme change signal."""
|
"""Connect to the theme change signal."""
|
||||||
qapp = QApplication.instance()
|
qapp = QApplication.instance()
|
||||||
if hasattr(qapp, "theme_signal"):
|
if hasattr(qapp, "theme"):
|
||||||
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
@SafeSlot()
|
||||||
def _update_theme(self, theme: str | None = None):
|
def _update_theme(self, theme: str | None = None):
|
||||||
"""Update the theme."""
|
"""Update the theme."""
|
||||||
if theme is None:
|
if theme is None:
|
||||||
@@ -124,6 +116,26 @@ class BECWidget(BECConnector):
|
|||||||
screenshot.save(file_name)
|
screenshot.save(file_name)
|
||||||
logger.info(f"Screenshot saved to {file_name}")
|
logger.info(f"Screenshot saved to {file_name}")
|
||||||
|
|
||||||
|
def attach(self):
|
||||||
|
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||||
|
if dock is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not dock.isFloating():
|
||||||
|
return
|
||||||
|
dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock)
|
||||||
|
|
||||||
|
def detach(self):
|
||||||
|
"""
|
||||||
|
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||||
|
"""
|
||||||
|
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
|
||||||
|
if dock is None:
|
||||||
|
return
|
||||||
|
if dock.isFloating():
|
||||||
|
return
|
||||||
|
dock.setFloating()
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Cleanup the widget."""
|
"""Cleanup the widget."""
|
||||||
with RPCRegister.delayed_broadcast():
|
with RPCRegister.delayed_broadcast():
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
import bec_qthemes
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
|
from bec_qthemes import apply_theme as apply_theme_global
|
||||||
from pydantic_core import PydanticCustomError
|
from pydantic_core import PydanticCustomError
|
||||||
|
from qtpy.QtCore import QEvent, QEventLoop
|
||||||
from qtpy.QtGui import QColor
|
from qtpy.QtGui import QColor
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
@@ -23,7 +23,10 @@ def get_theme_name():
|
|||||||
|
|
||||||
|
|
||||||
def get_theme_palette():
|
def get_theme_palette():
|
||||||
return bec_qthemes.load_palette(get_theme_name())
|
# FIXME this is legacy code, should be removed in the future
|
||||||
|
app = QApplication.instance()
|
||||||
|
palette = app.palette()
|
||||||
|
return palette
|
||||||
|
|
||||||
|
|
||||||
def get_accent_colors() -> AccentColors | None:
|
def get_accent_colors() -> AccentColors | None:
|
||||||
@@ -36,105 +39,18 @@ def get_accent_colors() -> AccentColors | None:
|
|||||||
return QApplication.instance().theme.accent_colors
|
return QApplication.instance().theme.accent_colors
|
||||||
|
|
||||||
|
|
||||||
def _theme_update_callback():
|
def process_all_deferred_deletes(qapp):
|
||||||
"""
|
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||||
Internal callback function to update the theme based on the system theme.
|
qapp.processEvents(QEventLoop.AllEvents)
|
||||||
"""
|
|
||||||
app = QApplication.instance()
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
app.theme.theme = app.os_listener._theme.lower()
|
|
||||||
app.theme_signal.theme_updated.emit(app.theme.theme)
|
|
||||||
apply_theme(app.os_listener._theme.lower())
|
|
||||||
|
|
||||||
|
|
||||||
def set_theme(theme: Literal["dark", "light", "auto"]):
|
|
||||||
"""
|
|
||||||
Set the theme for the application.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
|
|
||||||
"""
|
|
||||||
app = QApplication.instance()
|
|
||||||
bec_qthemes.setup_theme(theme, install_event_filter=False)
|
|
||||||
|
|
||||||
app.theme_signal.theme_updated.emit(theme)
|
|
||||||
apply_theme(theme)
|
|
||||||
|
|
||||||
if theme != "auto":
|
|
||||||
return
|
|
||||||
|
|
||||||
if not hasattr(app, "os_listener") or app.os_listener is None:
|
|
||||||
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
|
|
||||||
app.installEventFilter(app.os_listener)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_theme(theme: Literal["dark", "light"]):
|
def apply_theme(theme: Literal["dark", "light"]):
|
||||||
"""
|
"""
|
||||||
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
|
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
|
||||||
"""
|
"""
|
||||||
app = QApplication.instance()
|
process_all_deferred_deletes(QApplication.instance())
|
||||||
graphic_layouts = [
|
apply_theme_global(theme)
|
||||||
child
|
process_all_deferred_deletes(QApplication.instance())
|
||||||
for top in app.topLevelWidgets()
|
|
||||||
for child in top.findChildren(pg.GraphicsLayoutWidget)
|
|
||||||
]
|
|
||||||
|
|
||||||
plot_items = [
|
|
||||||
item
|
|
||||||
for gl in graphic_layouts
|
|
||||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
|
||||||
if isinstance(item, pg.PlotItem)
|
|
||||||
]
|
|
||||||
|
|
||||||
histograms = [
|
|
||||||
item
|
|
||||||
for gl in graphic_layouts
|
|
||||||
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
|
|
||||||
if isinstance(item, pg.HistogramLUTItem)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Update background color based on the theme
|
|
||||||
if theme == "light":
|
|
||||||
background_color = "#e9ecef" # Subtle contrast for light mode
|
|
||||||
foreground_color = "#141414"
|
|
||||||
label_color = "#000000"
|
|
||||||
axis_color = "#666666"
|
|
||||||
else:
|
|
||||||
background_color = "#141414" # Dark mode
|
|
||||||
foreground_color = "#e9ecef"
|
|
||||||
label_color = "#FFFFFF"
|
|
||||||
axis_color = "#CCCCCC"
|
|
||||||
|
|
||||||
# update GraphicsLayoutWidget
|
|
||||||
pg.setConfigOptions(foreground=foreground_color, background=background_color)
|
|
||||||
for pg_widget in graphic_layouts:
|
|
||||||
pg_widget.setBackground(background_color)
|
|
||||||
|
|
||||||
# update PlotItems
|
|
||||||
for plot_item in plot_items:
|
|
||||||
for axis in ["left", "right", "top", "bottom"]:
|
|
||||||
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
|
|
||||||
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
|
|
||||||
|
|
||||||
# Change title color
|
|
||||||
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
|
|
||||||
|
|
||||||
# Change legend color
|
|
||||||
if hasattr(plot_item, "legend") and plot_item.legend is not None:
|
|
||||||
plot_item.legend.setLabelTextColor(label_color)
|
|
||||||
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
|
|
||||||
for sample, label in plot_item.legend.items:
|
|
||||||
label_text = label.text
|
|
||||||
label.setText(label_text, color=label_color)
|
|
||||||
|
|
||||||
# update HistogramLUTItem
|
|
||||||
for histogram in histograms:
|
|
||||||
histogram.axis.setPen(pg.mkPen(color=axis_color))
|
|
||||||
histogram.axis.setTextPen(pg.mkPen(color=label_color))
|
|
||||||
|
|
||||||
# now define stylesheet according to theme and apply it
|
|
||||||
style = bec_qthemes.load_stylesheet(theme)
|
|
||||||
app.setStyleSheet(style)
|
|
||||||
|
|
||||||
|
|
||||||
class Colors:
|
class Colors:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from qtpy.QtWidgets import (
|
|||||||
QPushButton,
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSpacerItem,
|
QSpacerItem,
|
||||||
|
QToolButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
@@ -122,15 +123,14 @@ class CompactPopupWidget(QWidget):
|
|||||||
self.compact_view_widget = QWidget(self)
|
self.compact_view_widget = QWidget(self)
|
||||||
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
QHBoxLayout(self.compact_view_widget)
|
QHBoxLayout(self.compact_view_widget)
|
||||||
self.compact_view_widget.layout().setSpacing(0)
|
self.compact_view_widget.layout().setSpacing(5)
|
||||||
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
|
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
|
||||||
self.compact_view_widget.layout().addSpacerItem(
|
self.compact_view_widget.layout().addSpacerItem(
|
||||||
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
|
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
)
|
)
|
||||||
self.compact_label = QLabel(self.compact_view_widget)
|
self.compact_label = QLabel(self.compact_view_widget)
|
||||||
self.compact_status = LedLabel(self.compact_view_widget)
|
self.compact_status = LedLabel(self.compact_view_widget)
|
||||||
self.compact_show_popup = QPushButton(self.compact_view_widget)
|
self.compact_show_popup = QToolButton(self.compact_view_widget)
|
||||||
self.compact_show_popup.setFlat(True)
|
|
||||||
self.compact_show_popup.setIcon(
|
self.compact_show_popup.setIcon(
|
||||||
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
|
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import functools
|
|||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
import shiboken6
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
|
from louie.saferef import safe_ref
|
||||||
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
|
||||||
|
|
||||||
@@ -90,6 +92,52 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_connect_slot(weak_instance, weak_slot, *connect_args):
|
||||||
|
"""Internal function used by SafeConnect to handle weak references to slots."""
|
||||||
|
instance = weak_instance()
|
||||||
|
slot_func = weak_slot()
|
||||||
|
|
||||||
|
# Check if the python object has already been garbage collected
|
||||||
|
if instance is None or slot_func is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if the python object has already been marked for deletion
|
||||||
|
if getattr(instance, "_destroyed", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if the C++ object is still valid
|
||||||
|
if not shiboken6.isValid(instance):
|
||||||
|
return
|
||||||
|
|
||||||
|
if connect_args:
|
||||||
|
slot_func(*connect_args)
|
||||||
|
slot_func()
|
||||||
|
|
||||||
|
|
||||||
|
def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name
|
||||||
|
"""
|
||||||
|
Method to safely handle Qt signal-slot connections. The python object is only forwarded
|
||||||
|
as a weak reference to avoid stale objects.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
instance: The instance to connect.
|
||||||
|
signal: The signal to connect to.
|
||||||
|
slot: The slot to connect.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
|
||||||
|
|
||||||
|
"""
|
||||||
|
weak_instance = safe_ref(instance)
|
||||||
|
weak_slot = safe_ref(slot)
|
||||||
|
|
||||||
|
# Create a partial function that will check weak references before calling the actual slot
|
||||||
|
safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot)
|
||||||
|
|
||||||
|
# Connect the signal to the safe connect slot wrapper
|
||||||
|
return signal.connect(safe_slot)
|
||||||
|
|
||||||
|
|
||||||
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
|
||||||
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
|
||||||
to the passed function, to display errors instead of potentially raising an exception
|
to the passed function, to display errors instead of potentially raising an exception
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from qtpy.QtCore import Property
|
from qtpy.QtCore import Property, Qt
|
||||||
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||||
|
|
||||||
|
|
||||||
class RoundedFrame(QFrame):
|
class RoundedFrame(QFrame):
|
||||||
|
# TODO this should be removed completely in favor of QSS styling, no time now
|
||||||
"""
|
"""
|
||||||
A custom QFrame with rounded corners and optional theme updates.
|
A custom QFrame with rounded corners and optional theme updates.
|
||||||
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
|
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
|
||||||
@@ -28,6 +29,9 @@ class RoundedFrame(QFrame):
|
|||||||
self.setProperty("skip_settings", True)
|
self.setProperty("skip_settings", True)
|
||||||
self.setObjectName("roundedFrame")
|
self.setObjectName("roundedFrame")
|
||||||
|
|
||||||
|
# Ensure QSS can paint background/border on this widget
|
||||||
|
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||||
|
|
||||||
# Create a layout for the frame
|
# Create a layout for the frame
|
||||||
if orientation == "vertical":
|
if orientation == "vertical":
|
||||||
self.layout = QVBoxLayout(self)
|
self.layout = QVBoxLayout(self)
|
||||||
@@ -45,22 +49,10 @@ class RoundedFrame(QFrame):
|
|||||||
|
|
||||||
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
|
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
|
||||||
self.apply_plot_widget_style()
|
self.apply_plot_widget_style()
|
||||||
|
self.update_style()
|
||||||
|
|
||||||
def apply_theme(self, theme: str):
|
def apply_theme(self, theme: str):
|
||||||
"""
|
"""Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven."""
|
||||||
Apply the theme to the frame and its content if theme updates are enabled.
|
|
||||||
"""
|
|
||||||
if self.content_widget is not None and isinstance(
|
|
||||||
self.content_widget, pg.GraphicsLayoutWidget
|
|
||||||
):
|
|
||||||
self.content_widget.setBackground(self.background_color)
|
|
||||||
|
|
||||||
# Update background color based on the theme
|
|
||||||
if theme == "light":
|
|
||||||
self.background_color = "#e9ecef" # Subtle contrast for light mode
|
|
||||||
else:
|
|
||||||
self.background_color = "#141414" # Dark mode
|
|
||||||
|
|
||||||
self.update_style()
|
self.update_style()
|
||||||
|
|
||||||
@Property(int)
|
@Property(int)
|
||||||
@@ -77,12 +69,10 @@ class RoundedFrame(QFrame):
|
|||||||
"""
|
"""
|
||||||
Update the style of the frame based on the background color.
|
Update the style of the frame based on the background color.
|
||||||
"""
|
"""
|
||||||
if self.background_color:
|
|
||||||
self.setStyleSheet(
|
self.setStyleSheet(
|
||||||
f"""
|
f"""
|
||||||
QFrame#roundedFrame {{
|
QFrame#roundedFrame {{
|
||||||
background-color: {self.background_color};
|
border-radius: {self._radius}px;
|
||||||
border-radius: {self._radius}; /* Rounded corners */
|
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -90,21 +80,10 @@ class RoundedFrame(QFrame):
|
|||||||
|
|
||||||
def apply_plot_widget_style(self, border: str = "none"):
|
def apply_plot_widget_style(self, border: str = "none"):
|
||||||
"""
|
"""
|
||||||
Automatically apply background, border, and axis styles to the PlotWidget.
|
Let QSS/pyqtgraph handle plot styling; avoid overriding here.
|
||||||
|
|
||||||
Args:
|
|
||||||
border (str): Border style (e.g., 'none', '1px solid red').
|
|
||||||
"""
|
"""
|
||||||
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
|
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
|
||||||
# Apply border style via stylesheet
|
self.content_widget.setStyleSheet("")
|
||||||
self.content_widget.setStyleSheet(
|
|
||||||
f"""
|
|
||||||
GraphicsLayoutWidget {{
|
|
||||||
border: {border}; /* Explicitly set the border */
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.content_widget.setBackground(self.background_color)
|
|
||||||
|
|
||||||
|
|
||||||
class ExampleApp(QWidget): # pragma: no cover
|
class ExampleApp(QWidget): # pragma: no cover
|
||||||
@@ -128,24 +107,14 @@ class ExampleApp(QWidget): # pragma: no cover
|
|||||||
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
|
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
|
||||||
plot2.plot_item = plot_item_2
|
plot2.plot_item = plot_item_2
|
||||||
|
|
||||||
# Wrap PlotWidgets in RoundedFrame
|
# Add to layout (no RoundedFrame wrapper; QSS styles plots)
|
||||||
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
|
|
||||||
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
|
|
||||||
|
|
||||||
# Add to layout
|
|
||||||
layout.addWidget(dark_button)
|
layout.addWidget(dark_button)
|
||||||
layout.addWidget(rounded_plot1)
|
layout.addWidget(plot1)
|
||||||
layout.addWidget(rounded_plot2)
|
layout.addWidget(plot2)
|
||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
from qtpy.QtCore import QTimer
|
# Theme flip demo removed; global theming applies automatically
|
||||||
|
|
||||||
def change_theme():
|
|
||||||
rounded_plot1.apply_theme("light")
|
|
||||||
rounded_plot2.apply_theme("dark")
|
|
||||||
|
|
||||||
QTimer.singleShot(100, change_theme)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from bec_lib.codecs import BECCodec
|
||||||
from bec_lib.serialization import msgpack
|
from bec_lib.serialization import msgpack
|
||||||
from qtpy.QtCore import QPointF
|
from qtpy.QtCore import QPointF
|
||||||
|
|
||||||
@@ -6,28 +9,15 @@ def register_serializer_extension():
|
|||||||
"""
|
"""
|
||||||
Register the serializer extension for the BECConnector.
|
Register the serializer extension for the BECConnector.
|
||||||
"""
|
"""
|
||||||
if not module_is_registered("bec_widgets.utils.serialization"):
|
if not msgpack.is_registered(QPointF):
|
||||||
msgpack.register_object_hook(encode_qpointf, decode_qpointf)
|
msgpack.register_codec(QPointFEncoder)
|
||||||
|
|
||||||
|
|
||||||
def module_is_registered(module_name: str) -> bool:
|
class QPointFEncoder(BECCodec):
|
||||||
"""
|
obj_type: Type = QPointF
|
||||||
Check if the module is registered in the encoder.
|
|
||||||
|
|
||||||
Args:
|
@staticmethod
|
||||||
module_name (str): The name of the module to check.
|
def encode(obj: QPointF) -> str:
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if the module is registered, False otherwise.
|
|
||||||
"""
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
for enc in msgpack._encoder:
|
|
||||||
if enc[0].__module__ == module_name:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def encode_qpointf(obj):
|
|
||||||
"""
|
"""
|
||||||
Encode a QPointF object to a list of floats. As this is mostly used for sending
|
Encode a QPointF object to a list of floats. As this is mostly used for sending
|
||||||
data to the client, it is not necessary to convert it back to a QPointF object.
|
data to the client, it is not necessary to convert it back to a QPointF object.
|
||||||
@@ -36,9 +26,9 @@ def encode_qpointf(obj):
|
|||||||
return [obj.x(), obj.y()]
|
return [obj.x(), obj.y()]
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def decode_qpointf(obj):
|
def decode(type_name: str, data: list[float]) -> list[float]:
|
||||||
"""
|
"""
|
||||||
no-op function since QPointF is encoded as a list of floats.
|
no-op function since QPointF is encoded as a list of floats.
|
||||||
"""
|
"""
|
||||||
return obj
|
return data
|
||||||
|
|||||||
@@ -446,6 +446,8 @@ class ExpandableMenuAction(ToolBarAction):
|
|||||||
|
|
||||||
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
|
||||||
button = QToolButton(toolbar)
|
button = QToolButton(toolbar)
|
||||||
|
button.setObjectName("toolbarMenuButton")
|
||||||
|
button.setAutoRaise(True)
|
||||||
if self.icon_path:
|
if self.icon_path:
|
||||||
button.setIcon(QIcon(self.icon_path))
|
button.setIcon(QIcon(self.icon_path))
|
||||||
button.setText(self.tooltip)
|
button.setText(self.tooltip)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from qtpy.QtCore import QSize, Qt
|
|||||||
from qtpy.QtGui import QAction, QColor
|
from qtpy.QtGui import QAction, QColor
|
||||||
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.colors import get_theme_name, set_theme
|
from bec_widgets.utils.colors import apply_theme, get_theme_name
|
||||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
|
||||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||||
from bec_widgets.utils.toolbars.connections import BundleConnection
|
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||||
@@ -507,7 +507,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
self.test_label.setText("FPS Monitor Disabled")
|
self.test_label.setText("FPS Monitor Disabled")
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("light")
|
apply_theme("light")
|
||||||
main_window = MainWindow()
|
main_window = MainWindow()
|
||||||
main_window.show()
|
main_window.show()
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|||||||
@@ -465,13 +465,19 @@ class WidgetHierarchy:
|
|||||||
"""
|
"""
|
||||||
from bec_widgets.utils import BECConnector
|
from bec_widgets.utils import BECConnector
|
||||||
|
|
||||||
|
# Guard against deleted/invalid Qt wrappers
|
||||||
if not shb.isValid(widget):
|
if not shb.isValid(widget):
|
||||||
return None
|
return None
|
||||||
parent = widget.parent()
|
|
||||||
|
# Retrieve first parent
|
||||||
|
parent = widget.parent() if hasattr(widget, "parent") else None
|
||||||
|
# Walk up, validating each step
|
||||||
while parent is not None:
|
while parent is not None:
|
||||||
|
if not shb.isValid(parent):
|
||||||
|
return None
|
||||||
if isinstance(parent, BECConnector):
|
if isinstance(parent, BECConnector):
|
||||||
return parent
|
return parent
|
||||||
parent = parent.parent()
|
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -553,6 +559,64 @@ class WidgetHierarchy:
|
|||||||
WidgetIO.set_value(child, value)
|
WidgetIO.set_value(child, value)
|
||||||
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
WidgetHierarchy.import_config_from_dict(child, widget_config, set_values)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_bec_connectors_from_parent(widget) -> list:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
connectors: list[BECConnector] = []
|
||||||
|
if isinstance(widget, BECConnector):
|
||||||
|
connectors.append(widget)
|
||||||
|
for child in widget.findChildren(BECConnector):
|
||||||
|
if WidgetHierarchy._get_becwidget_ancestor(child) is widget:
|
||||||
|
connectors.append(child)
|
||||||
|
return connectors
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_ancestor(widget, ancestor_class) -> QWidget | None:
|
||||||
|
"""
|
||||||
|
Traverse up the parent chain to find the nearest ancestor matching ancestor_class.
|
||||||
|
ancestor_class may be a class or a class-name string.
|
||||||
|
Returns the matching ancestor, or None if none is found.
|
||||||
|
"""
|
||||||
|
# Guard against deleted/invalid Qt wrappers
|
||||||
|
if not shb.isValid(widget):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# If searching for BECConnector specifically, reuse the dedicated helper
|
||||||
|
try:
|
||||||
|
from bec_widgets.utils import BECConnector # local import to avoid cycles
|
||||||
|
|
||||||
|
if ancestor_class is BECConnector or (
|
||||||
|
isinstance(ancestor_class, str) and ancestor_class == "BECConnector"
|
||||||
|
):
|
||||||
|
return WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||||
|
except Exception:
|
||||||
|
# If import fails, fall back to generic traversal below
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Generic traversal across QObject parent chain
|
||||||
|
parent = getattr(widget, "parent", None)
|
||||||
|
if callable(parent):
|
||||||
|
parent = parent()
|
||||||
|
while parent is not None:
|
||||||
|
if not shb.isValid(parent):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if isinstance(ancestor_class, str):
|
||||||
|
if parent.__class__.__name__ == ancestor_class:
|
||||||
|
return parent
|
||||||
|
else:
|
||||||
|
if isinstance(parent, ancestor_class):
|
||||||
|
return parent
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Example usage
|
# Example usage
|
||||||
def hierarchy_example(): # pragma: no cover
|
def hierarchy_example(): # pragma: no cover
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ from qtpy.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|
||||||
@@ -29,43 +31,58 @@ class WidgetStateManager:
|
|||||||
def __init__(self, widget):
|
def __init__(self, widget):
|
||||||
self.widget = widget
|
self.widget = widget
|
||||||
|
|
||||||
def save_state(self, filename: str = None):
|
def save_state(self, filename: str | None = None, settings: QSettings | None = None):
|
||||||
"""
|
"""
|
||||||
Save the state of the widget to an INI file.
|
Save the state of the widget to an INI file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename(str): The filename to save the state to.
|
filename(str): The filename to save the state to.
|
||||||
|
settings(QSettings): Optional QSettings object to save the state to.
|
||||||
"""
|
"""
|
||||||
if not filename:
|
if not filename and not settings:
|
||||||
filename, _ = QFileDialog.getSaveFileName(
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
self.widget, "Save Settings", "", "INI Files (*.ini)"
|
self.widget, "Save Settings", "", "INI Files (*.ini)"
|
||||||
)
|
)
|
||||||
if filename:
|
if filename:
|
||||||
settings = QSettings(filename, QSettings.IniFormat)
|
settings = QSettings(filename, QSettings.IniFormat)
|
||||||
self._save_widget_state_qsettings(self.widget, settings)
|
self._save_widget_state_qsettings(self.widget, settings)
|
||||||
|
elif settings:
|
||||||
|
# If settings are provided, save the state to the provided QSettings object
|
||||||
|
self._save_widget_state_qsettings(self.widget, settings)
|
||||||
|
else:
|
||||||
|
logger.warning("No filename or settings provided for saving state.")
|
||||||
|
|
||||||
def load_state(self, filename: str = None):
|
def load_state(self, filename: str | None = None, settings: QSettings | None = None):
|
||||||
"""
|
"""
|
||||||
Load the state of the widget from an INI file.
|
Load the state of the widget from an INI file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename(str): The filename to load the state from.
|
filename(str): The filename to load the state from.
|
||||||
|
settings(QSettings): Optional QSettings object to load the state from.
|
||||||
"""
|
"""
|
||||||
if not filename:
|
if not filename and not settings:
|
||||||
filename, _ = QFileDialog.getOpenFileName(
|
filename, _ = QFileDialog.getOpenFileName(
|
||||||
self.widget, "Load Settings", "", "INI Files (*.ini)"
|
self.widget, "Load Settings", "", "INI Files (*.ini)"
|
||||||
)
|
)
|
||||||
if filename:
|
if filename:
|
||||||
settings = QSettings(filename, QSettings.IniFormat)
|
settings = QSettings(filename, QSettings.IniFormat)
|
||||||
self._load_widget_state_qsettings(self.widget, settings)
|
self._load_widget_state_qsettings(self.widget, settings)
|
||||||
|
elif settings:
|
||||||
|
# If settings are provided, load the state from the provided QSettings object
|
||||||
|
self._load_widget_state_qsettings(self.widget, settings)
|
||||||
|
else:
|
||||||
|
logger.warning("No filename or settings provided for saving state.")
|
||||||
|
|
||||||
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
def _save_widget_state_qsettings(
|
||||||
|
self, widget: QWidget, settings: QSettings, recursive: bool = True
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Save the state of the widget to QSettings.
|
Save the state of the widget to QSettings.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
widget(QWidget): The widget to save the state for.
|
widget(QWidget): The widget to save the state for.
|
||||||
settings(QSettings): The QSettings object to save the state to.
|
settings(QSettings): The QSettings object to save the state to.
|
||||||
|
recursive(bool): Whether to recursively save the state of child widgets.
|
||||||
"""
|
"""
|
||||||
if widget.property("skip_settings") is True:
|
if widget.property("skip_settings") is True:
|
||||||
return
|
return
|
||||||
@@ -88,21 +105,32 @@ class WidgetStateManager:
|
|||||||
settings.endGroup()
|
settings.endGroup()
|
||||||
|
|
||||||
# Recursively process children (only if they aren't skipped)
|
# Recursively process children (only if they aren't skipped)
|
||||||
for child in widget.children():
|
if not recursive:
|
||||||
|
return
|
||||||
|
|
||||||
|
direct_children = widget.children()
|
||||||
|
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
|
||||||
|
all_children = list(
|
||||||
|
set(direct_children) | set(bec_connector_children)
|
||||||
|
) # to avoid duplicates
|
||||||
|
for child in all_children:
|
||||||
if (
|
if (
|
||||||
child.objectName()
|
child.objectName()
|
||||||
and child.property("skip_settings") is not True
|
and child.property("skip_settings") is not True
|
||||||
and not isinstance(child, QLabel)
|
and not isinstance(child, QLabel)
|
||||||
):
|
):
|
||||||
self._save_widget_state_qsettings(child, settings)
|
self._save_widget_state_qsettings(child, settings, False)
|
||||||
|
|
||||||
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings):
|
def _load_widget_state_qsettings(
|
||||||
|
self, widget: QWidget, settings: QSettings, recursive: bool = True
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Load the state of the widget from QSettings.
|
Load the state of the widget from QSettings.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
widget(QWidget): The widget to load the state for.
|
widget(QWidget): The widget to load the state for.
|
||||||
settings(QSettings): The QSettings object to load the state from.
|
settings(QSettings): The QSettings object to load the state from.
|
||||||
|
recursive(bool): Whether to recursively load the state of child widgets.
|
||||||
"""
|
"""
|
||||||
if widget.property("skip_settings") is True:
|
if widget.property("skip_settings") is True:
|
||||||
return
|
return
|
||||||
@@ -118,14 +146,21 @@ class WidgetStateManager:
|
|||||||
widget.setProperty(name, value)
|
widget.setProperty(name, value)
|
||||||
settings.endGroup()
|
settings.endGroup()
|
||||||
|
|
||||||
|
if not recursive:
|
||||||
|
return
|
||||||
# Recursively process children (only if they aren't skipped)
|
# Recursively process children (only if they aren't skipped)
|
||||||
for child in widget.children():
|
direct_children = widget.children()
|
||||||
|
bec_connector_children = WidgetHierarchy.get_bec_connectors_from_parent(widget)
|
||||||
|
all_children = list(
|
||||||
|
set(direct_children) | set(bec_connector_children)
|
||||||
|
) # to avoid duplicates
|
||||||
|
for child in all_children:
|
||||||
if (
|
if (
|
||||||
child.objectName()
|
child.objectName()
|
||||||
and child.property("skip_settings") is not True
|
and child.property("skip_settings") is not True
|
||||||
and not isinstance(child, QLabel)
|
and not isinstance(child, QLabel)
|
||||||
):
|
):
|
||||||
self._load_widget_state_qsettings(child, settings)
|
self._load_widget_state_qsettings(child, settings, False)
|
||||||
|
|
||||||
def _get_full_widget_name(self, widget: QWidget):
|
def _get_full_widget_name(self, widget: QWidget):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,911 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Literal, cast
|
||||||
|
|
||||||
|
import PySide6QtAds as QtAds
|
||||||
|
from PySide6QtAds import CDockManager, CDockWidget
|
||||||
|
from qtpy.QtCore import Signal
|
||||||
|
from qtpy.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QCheckBox,
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QInputDialog,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
from shiboken6 import isValid
|
||||||
|
|
||||||
|
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||||
|
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
|
||||||
|
from bec_widgets.utils import BECDispatcher
|
||||||
|
from bec_widgets.utils.property_editor import PropertyEditor
|
||||||
|
from bec_widgets.utils.toolbars.actions import (
|
||||||
|
ExpandableMenuAction,
|
||||||
|
MaterialIconAction,
|
||||||
|
WidgetAction,
|
||||||
|
)
|
||||||
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||||
|
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||||
|
from bec_widgets.utils.widget_state_manager import WidgetStateManager
|
||||||
|
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||||
|
SETTINGS_KEYS,
|
||||||
|
is_profile_readonly,
|
||||||
|
list_profiles,
|
||||||
|
open_settings,
|
||||||
|
profile_path,
|
||||||
|
read_manifest,
|
||||||
|
set_profile_readonly,
|
||||||
|
write_manifest,
|
||||||
|
)
|
||||||
|
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
|
||||||
|
WorkspaceConnection,
|
||||||
|
workspace_bundle,
|
||||||
|
)
|
||||||
|
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
|
||||||
|
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||||
|
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||||
|
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
|
||||||
|
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
|
||||||
|
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
|
||||||
|
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
|
||||||
|
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||||
|
from bec_widgets.widgets.progress.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.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||||
|
|
||||||
|
|
||||||
|
class DockSettingsDialog(QDialog):
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget, target: QWidget):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Dock Settings")
|
||||||
|
self.setModal(True)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Property editor
|
||||||
|
self.prop_editor = PropertyEditor(target, self, show_only_bec=True)
|
||||||
|
layout.addWidget(self.prop_editor)
|
||||||
|
|
||||||
|
|
||||||
|
class SaveProfileDialog(QDialog):
|
||||||
|
"""Dialog for saving workspace profiles with read-only option."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget, current_name: str = ""):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Save Workspace Profile")
|
||||||
|
self.setModal(True)
|
||||||
|
self.resize(400, 150)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# Name input
|
||||||
|
name_row = QHBoxLayout()
|
||||||
|
name_row.addWidget(QLabel("Profile Name:"))
|
||||||
|
self.name_edit = QLineEdit(current_name)
|
||||||
|
self.name_edit.setPlaceholderText("Enter profile name...")
|
||||||
|
name_row.addWidget(self.name_edit)
|
||||||
|
layout.addLayout(name_row)
|
||||||
|
|
||||||
|
# Read-only checkbox
|
||||||
|
self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)")
|
||||||
|
layout.addWidget(self.readonly_checkbox)
|
||||||
|
|
||||||
|
# Info label
|
||||||
|
info_label = QLabel("Read-only profiles are protected from modification and deletion.")
|
||||||
|
info_label.setStyleSheet("color: gray; font-size: 10px;")
|
||||||
|
layout.addWidget(info_label)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
btn_row.addStretch(1)
|
||||||
|
self.save_btn = QPushButton("Save")
|
||||||
|
self.save_btn.setDefault(True)
|
||||||
|
cancel_btn = QPushButton("Cancel")
|
||||||
|
self.save_btn.clicked.connect(self.accept)
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
btn_row.addWidget(self.save_btn)
|
||||||
|
btn_row.addWidget(cancel_btn)
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
# Enable/disable save button based on name input
|
||||||
|
self.name_edit.textChanged.connect(self._update_save_button)
|
||||||
|
self._update_save_button()
|
||||||
|
|
||||||
|
def _update_save_button(self):
|
||||||
|
"""Enable save button only when name is not empty."""
|
||||||
|
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
|
||||||
|
|
||||||
|
def get_profile_name(self) -> str:
|
||||||
|
"""Get the entered profile name."""
|
||||||
|
return self.name_edit.text().strip()
|
||||||
|
|
||||||
|
def is_readonly(self) -> bool:
|
||||||
|
"""Check if the profile should be marked as read-only."""
|
||||||
|
return self.readonly_checkbox.isChecked()
|
||||||
|
|
||||||
|
|
||||||
|
class AdvancedDockArea(BECWidget, QWidget):
|
||||||
|
RPC = True
|
||||||
|
PLUGIN = False
|
||||||
|
USER_ACCESS = [
|
||||||
|
"new",
|
||||||
|
"widget_map",
|
||||||
|
"widget_list",
|
||||||
|
"lock_workspace",
|
||||||
|
"attach_all",
|
||||||
|
"delete_all",
|
||||||
|
"mode",
|
||||||
|
"mode.setter",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Define a signal for mode changes
|
||||||
|
mode_changed = Signal(str)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None,
|
||||||
|
mode: str = "developer",
|
||||||
|
default_add_direction: Literal["left", "right", "top", "bottom"] = "right",
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(parent=parent, *args, **kwargs)
|
||||||
|
|
||||||
|
# Title (as a top-level QWidget it can have a window title)
|
||||||
|
self.setWindowTitle("Advanced Dock Area")
|
||||||
|
|
||||||
|
# Top-level layout hosting a toolbar and the dock manager
|
||||||
|
self._root_layout = QVBoxLayout(self)
|
||||||
|
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._root_layout.setSpacing(0)
|
||||||
|
|
||||||
|
# Init Dock Manager
|
||||||
|
self.dock_manager = CDockManager(self)
|
||||||
|
self.dock_manager.setStyleSheet("")
|
||||||
|
|
||||||
|
# Dock manager helper variables
|
||||||
|
self._locked = False # Lock state of the workspace
|
||||||
|
|
||||||
|
# Initialize mode property first (before toolbar setup)
|
||||||
|
self._mode = "developer"
|
||||||
|
self._default_add_direction = (
|
||||||
|
default_add_direction
|
||||||
|
if default_add_direction in ("left", "right", "top", "bottom")
|
||||||
|
else "right"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Toolbar
|
||||||
|
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
|
||||||
|
self._setup_toolbar()
|
||||||
|
self._hook_toolbar()
|
||||||
|
|
||||||
|
# Place toolbar and dock manager into layout
|
||||||
|
self._root_layout.addWidget(self.toolbar)
|
||||||
|
self._root_layout.addWidget(self.dock_manager, 1)
|
||||||
|
|
||||||
|
# Populate and hook the workspace combo
|
||||||
|
self._refresh_workspace_list()
|
||||||
|
|
||||||
|
# State manager
|
||||||
|
self.state_manager = WidgetStateManager(self)
|
||||||
|
|
||||||
|
# Developer mode state
|
||||||
|
self._editable = None
|
||||||
|
# Initialize default editable state based on current lock
|
||||||
|
self._set_editable(True) # default to editable; will sync toolbar toggle below
|
||||||
|
|
||||||
|
# Sync Developer toggle icon state after initial setup
|
||||||
|
dev_action = self.toolbar.components.get_action("developer_mode").action
|
||||||
|
dev_action.setChecked(self._editable)
|
||||||
|
|
||||||
|
# Apply the requested mode after everything is set up
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
def _make_dock(
|
||||||
|
self,
|
||||||
|
widget: QWidget,
|
||||||
|
*,
|
||||||
|
closable: bool,
|
||||||
|
floatable: bool,
|
||||||
|
movable: bool = True,
|
||||||
|
area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||||
|
start_floating: bool = False,
|
||||||
|
) -> CDockWidget:
|
||||||
|
dock = CDockWidget(widget.objectName())
|
||||||
|
dock.setWidget(widget)
|
||||||
|
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
|
||||||
|
dock.setFeature(CDockWidget.CustomCloseHandling, True)
|
||||||
|
dock.setFeature(CDockWidget.DockWidgetClosable, closable)
|
||||||
|
dock.setFeature(CDockWidget.DockWidgetFloatable, floatable)
|
||||||
|
dock.setFeature(CDockWidget.DockWidgetMovable, movable)
|
||||||
|
|
||||||
|
self._install_dock_settings_action(dock, widget)
|
||||||
|
|
||||||
|
def on_dock_close():
|
||||||
|
widget.close()
|
||||||
|
dock.closeDockWidget()
|
||||||
|
dock.deleteDockWidget()
|
||||||
|
|
||||||
|
def on_widget_destroyed():
|
||||||
|
if not isValid(dock):
|
||||||
|
return
|
||||||
|
dock.closeDockWidget()
|
||||||
|
dock.deleteDockWidget()
|
||||||
|
|
||||||
|
dock.closeRequested.connect(on_dock_close)
|
||||||
|
if hasattr(widget, "widget_removed"):
|
||||||
|
widget.widget_removed.connect(on_widget_destroyed)
|
||||||
|
|
||||||
|
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
|
||||||
|
self.dock_manager.addDockWidget(area, dock)
|
||||||
|
if start_floating:
|
||||||
|
dock.setFloating()
|
||||||
|
return dock
|
||||||
|
|
||||||
|
def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||||
|
action = MaterialIconAction(
|
||||||
|
icon_name="settings", tooltip="Dock settings", filled=True, parent=self
|
||||||
|
).action
|
||||||
|
action.setToolTip("Dock settings")
|
||||||
|
action.setObjectName("dockSettingsAction")
|
||||||
|
action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget))
|
||||||
|
dock.setTitleBarActions([action])
|
||||||
|
dock.setting_action = action
|
||||||
|
|
||||||
|
def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None:
|
||||||
|
dlg = DockSettingsDialog(self, widget)
|
||||||
|
dlg.resize(600, 600)
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
|
def _apply_dock_lock(self, locked: bool) -> None:
|
||||||
|
if locked:
|
||||||
|
self.dock_manager.lockDockWidgetFeaturesGlobally()
|
||||||
|
else:
|
||||||
|
self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures)
|
||||||
|
|
||||||
|
def _delete_dock(self, dock: CDockWidget) -> None:
|
||||||
|
w = dock.widget()
|
||||||
|
if w and isValid(w):
|
||||||
|
w.close()
|
||||||
|
w.deleteLater()
|
||||||
|
if isValid(dock):
|
||||||
|
dock.closeDockWidget()
|
||||||
|
dock.deleteDockWidget()
|
||||||
|
|
||||||
|
def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea:
|
||||||
|
"""Return ADS DockWidgetArea from a human-friendly direction string.
|
||||||
|
If *where* is None, fall back to instance default.
|
||||||
|
"""
|
||||||
|
d = (where or getattr(self, "_default_add_direction", "right") or "right").lower()
|
||||||
|
mapping = {
|
||||||
|
"left": QtAds.DockWidgetArea.LeftDockWidgetArea,
|
||||||
|
"right": QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||||
|
"top": QtAds.DockWidgetArea.TopDockWidgetArea,
|
||||||
|
"bottom": QtAds.DockWidgetArea.BottomDockWidgetArea,
|
||||||
|
}
|
||||||
|
return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea)
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Toolbar Setup
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
def _setup_toolbar(self):
|
||||||
|
self.toolbar = ModularToolBar(parent=self)
|
||||||
|
|
||||||
|
PLOT_ACTIONS = {
|
||||||
|
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
|
||||||
|
"scatter_waveform": (
|
||||||
|
ScatterWaveform.ICON_NAME,
|
||||||
|
"Add Scatter Waveform",
|
||||||
|
"ScatterWaveform",
|
||||||
|
),
|
||||||
|
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
|
||||||
|
"image": (Image.ICON_NAME, "Add Image", "Image"),
|
||||||
|
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
|
||||||
|
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
|
||||||
|
}
|
||||||
|
DEVICE_ACTIONS = {
|
||||||
|
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
|
||||||
|
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
|
||||||
|
}
|
||||||
|
UTIL_ACTIONS = {
|
||||||
|
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
|
||||||
|
"vs_code": (VSCodeEditor.ICON_NAME, "Add VS Code", "VSCodeEditor"),
|
||||||
|
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
|
||||||
|
"progress_bar": (
|
||||||
|
RingProgressBar.ICON_NAME,
|
||||||
|
"Add Circular ProgressBar",
|
||||||
|
"RingProgressBar",
|
||||||
|
),
|
||||||
|
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
|
||||||
|
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create expandable menu actions (original behavior)
|
||||||
|
def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]):
|
||||||
|
self.toolbar.components.add_safe(
|
||||||
|
key,
|
||||||
|
ExpandableMenuAction(
|
||||||
|
label=label,
|
||||||
|
actions={
|
||||||
|
k: MaterialIconAction(
|
||||||
|
icon_name=v[0], tooltip=v[1], filled=True, parent=self
|
||||||
|
)
|
||||||
|
for k, v in mapping.items()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
b = ToolbarBundle(key, self.toolbar.components)
|
||||||
|
b.add_action(key)
|
||||||
|
self.toolbar.add_bundle(b)
|
||||||
|
|
||||||
|
_build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS)
|
||||||
|
_build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS)
|
||||||
|
_build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS)
|
||||||
|
|
||||||
|
# Create flat toolbar bundles for each widget type
|
||||||
|
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||||
|
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
|
||||||
|
|
||||||
|
for action_id, (icon_name, tooltip, widget_type) in mapping.items():
|
||||||
|
# Create individual action for each widget type
|
||||||
|
flat_action_id = f"flat_{action_id}"
|
||||||
|
self.toolbar.components.add_safe(
|
||||||
|
flat_action_id,
|
||||||
|
MaterialIconAction(
|
||||||
|
icon_name=icon_name, tooltip=tooltip, filled=True, parent=self
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bundle.add_action(flat_action_id)
|
||||||
|
|
||||||
|
self.toolbar.add_bundle(bundle)
|
||||||
|
|
||||||
|
_build_flat_bundles("plots", PLOT_ACTIONS)
|
||||||
|
_build_flat_bundles("devices", DEVICE_ACTIONS)
|
||||||
|
_build_flat_bundles("utils", UTIL_ACTIONS)
|
||||||
|
|
||||||
|
# Workspace
|
||||||
|
spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components)
|
||||||
|
spacer = QWidget(parent=self.toolbar.components.toolbar)
|
||||||
|
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
|
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
|
||||||
|
spacer_bundle.add_action("spacer")
|
||||||
|
self.toolbar.add_bundle(spacer_bundle)
|
||||||
|
|
||||||
|
self.toolbar.add_bundle(workspace_bundle(self.toolbar.components))
|
||||||
|
self.toolbar.connect_bundle(
|
||||||
|
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dock actions
|
||||||
|
self.toolbar.components.add_safe(
|
||||||
|
"attach_all",
|
||||||
|
MaterialIconAction(
|
||||||
|
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.toolbar.components.add_safe(
|
||||||
|
"screenshot",
|
||||||
|
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
|
||||||
|
)
|
||||||
|
self.toolbar.components.add_safe(
|
||||||
|
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self)
|
||||||
|
)
|
||||||
|
# Developer mode toggle (moved from menu into toolbar)
|
||||||
|
self.toolbar.components.add_safe(
|
||||||
|
"developer_mode",
|
||||||
|
MaterialIconAction(
|
||||||
|
icon_name="code", tooltip="Developer Mode", checkable=True, parent=self
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bda = ToolbarBundle("dock_actions", self.toolbar.components)
|
||||||
|
bda.add_action("attach_all")
|
||||||
|
bda.add_action("screenshot")
|
||||||
|
bda.add_action("dark_mode")
|
||||||
|
bda.add_action("developer_mode")
|
||||||
|
self.toolbar.add_bundle(bda)
|
||||||
|
|
||||||
|
# Default bundle configuration (show menus by default)
|
||||||
|
self.toolbar.show_bundles(
|
||||||
|
[
|
||||||
|
"menu_plots",
|
||||||
|
"menu_devices",
|
||||||
|
"menu_utils",
|
||||||
|
"spacer_bundle",
|
||||||
|
"workspace",
|
||||||
|
"dock_actions",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store mappings on self for use in _hook_toolbar
|
||||||
|
self._ACTION_MAPPINGS = {
|
||||||
|
"menu_plots": PLOT_ACTIONS,
|
||||||
|
"menu_devices": DEVICE_ACTIONS,
|
||||||
|
"menu_utils": UTIL_ACTIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _hook_toolbar(self):
|
||||||
|
|
||||||
|
def _connect_menu(menu_key: str):
|
||||||
|
menu = self.toolbar.components.get_action(menu_key)
|
||||||
|
mapping = self._ACTION_MAPPINGS[menu_key]
|
||||||
|
for key, (_, _, widget_type) in mapping.items():
|
||||||
|
act = menu.actions[key].action
|
||||||
|
if widget_type == "LogPanel":
|
||||||
|
act.setEnabled(False) # keep disabled per issue #644
|
||||||
|
else:
|
||||||
|
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
|
||||||
|
|
||||||
|
_connect_menu("menu_plots")
|
||||||
|
_connect_menu("menu_devices")
|
||||||
|
_connect_menu("menu_utils")
|
||||||
|
|
||||||
|
# Connect flat toolbar actions
|
||||||
|
def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]):
|
||||||
|
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(widget=t))
|
||||||
|
|
||||||
|
_connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"])
|
||||||
|
_connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"])
|
||||||
|
_connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"])
|
||||||
|
|
||||||
|
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
|
||||||
|
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
|
||||||
|
# Developer mode toggle
|
||||||
|
self.toolbar.components.get_action("developer_mode").action.toggled.connect(
|
||||||
|
self._on_developer_mode_toggled
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_editable(self, editable: bool) -> None:
|
||||||
|
self.lock_workspace = not editable
|
||||||
|
self._editable = editable
|
||||||
|
|
||||||
|
# Sync the toolbar lock toggle with current mode
|
||||||
|
lock_action = self.toolbar.components.get_action("lock").action
|
||||||
|
lock_action.setChecked(not editable)
|
||||||
|
lock_action.setVisible(editable)
|
||||||
|
|
||||||
|
attach_all_action = self.toolbar.components.get_action("attach_all").action
|
||||||
|
attach_all_action.setVisible(editable)
|
||||||
|
|
||||||
|
# Show full creation menus only when editable; otherwise keep minimal set
|
||||||
|
if editable:
|
||||||
|
self.toolbar.show_bundles(
|
||||||
|
[
|
||||||
|
"menu_plots",
|
||||||
|
"menu_devices",
|
||||||
|
"menu_utils",
|
||||||
|
"spacer_bundle",
|
||||||
|
"workspace",
|
||||||
|
"dock_actions",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||||
|
|
||||||
|
# Keep Developer mode UI in sync
|
||||||
|
self.toolbar.components.get_action("developer_mode").action.setChecked(editable)
|
||||||
|
|
||||||
|
def _on_developer_mode_toggled(self, checked: bool) -> None:
|
||||||
|
"""Handle developer mode checkbox toggle."""
|
||||||
|
self._set_editable(checked)
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Adding widgets
|
||||||
|
################################################################################
|
||||||
|
@SafeSlot(popup_error=True)
|
||||||
|
def new(
|
||||||
|
self,
|
||||||
|
widget: BECWidget | str,
|
||||||
|
closable: bool = True,
|
||||||
|
floatable: bool = True,
|
||||||
|
movable: bool = True,
|
||||||
|
start_floating: bool = False,
|
||||||
|
where: Literal["left", "right", "top", "bottom"] | None = None,
|
||||||
|
) -> BECWidget:
|
||||||
|
"""
|
||||||
|
Create a new widget (or reuse an instance) and add it as a dock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
widget: Widget instance or a string widget type (factory-created).
|
||||||
|
closable: Whether the dock is closable.
|
||||||
|
floatable: Whether the dock is floatable.
|
||||||
|
movable: Whether the dock is movable.
|
||||||
|
start_floating: Start the dock in a floating state.
|
||||||
|
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
|
||||||
|
If None, uses the instance default passed at construction time.
|
||||||
|
Returns:
|
||||||
|
The widget instance.
|
||||||
|
"""
|
||||||
|
target_area = self._area_from_where(where)
|
||||||
|
|
||||||
|
# 1) Instantiate or look up the widget
|
||||||
|
if isinstance(widget, str):
|
||||||
|
widget = cast(BECWidget, widget_handler.create_widget(widget_type=widget, parent=self))
|
||||||
|
widget.name_established.connect(
|
||||||
|
lambda: self._create_dock_with_name(
|
||||||
|
widget=widget,
|
||||||
|
closable=closable,
|
||||||
|
floatable=floatable,
|
||||||
|
movable=movable,
|
||||||
|
start_floating=start_floating,
|
||||||
|
area=target_area,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return widget
|
||||||
|
|
||||||
|
# If a widget instance is passed, dock it immediately
|
||||||
|
self._create_dock_with_name(
|
||||||
|
widget=widget,
|
||||||
|
closable=closable,
|
||||||
|
floatable=floatable,
|
||||||
|
movable=movable,
|
||||||
|
start_floating=start_floating,
|
||||||
|
area=target_area,
|
||||||
|
)
|
||||||
|
return widget
|
||||||
|
|
||||||
|
def _create_dock_with_name(
|
||||||
|
self,
|
||||||
|
widget: BECWidget,
|
||||||
|
closable: bool = True,
|
||||||
|
floatable: bool = False,
|
||||||
|
movable: bool = True,
|
||||||
|
start_floating: bool = False,
|
||||||
|
area: QtAds.DockWidgetArea | None = None,
|
||||||
|
):
|
||||||
|
target_area = area or self._area_from_where(None)
|
||||||
|
self._make_dock(
|
||||||
|
widget,
|
||||||
|
closable=closable,
|
||||||
|
floatable=floatable,
|
||||||
|
movable=movable,
|
||||||
|
area=target_area,
|
||||||
|
start_floating=start_floating,
|
||||||
|
)
|
||||||
|
self.dock_manager.setFocus()
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Dock Management
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
def dock_map(self) -> dict[str, CDockWidget]:
|
||||||
|
"""
|
||||||
|
Return the dock widgets map as dictionary with names as keys and dock widgets as values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary mapping widget names to their corresponding dock widgets.
|
||||||
|
"""
|
||||||
|
return self.dock_manager.dockWidgetsMap()
|
||||||
|
|
||||||
|
def dock_list(self) -> list[CDockWidget]:
|
||||||
|
"""
|
||||||
|
Return the list of dock widgets.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of all dock widgets in the dock area.
|
||||||
|
"""
|
||||||
|
return self.dock_manager.dockWidgets()
|
||||||
|
|
||||||
|
def widget_map(self) -> dict[str, QWidget]:
|
||||||
|
"""
|
||||||
|
Return a dictionary mapping widget names to their corresponding BECWidget instances.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary mapping widget names to BECWidget instances.
|
||||||
|
"""
|
||||||
|
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
|
||||||
|
|
||||||
|
def widget_list(self) -> list[QWidget]:
|
||||||
|
"""
|
||||||
|
Return a list of all BECWidget instances in the dock area.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of all BECWidget instances in the dock area.
|
||||||
|
"""
|
||||||
|
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def attach_all(self):
|
||||||
|
"""
|
||||||
|
Return all floating docks to the dock area, preserving tab groups within each floating container.
|
||||||
|
"""
|
||||||
|
for container in self.dock_manager.floatingWidgets():
|
||||||
|
docks = container.dockWidgets()
|
||||||
|
if not docks:
|
||||||
|
continue
|
||||||
|
target = docks[0]
|
||||||
|
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target)
|
||||||
|
for d in docks[1:]:
|
||||||
|
self.dock_manager.addDockWidgetTab(
|
||||||
|
QtAds.DockWidgetArea.RightDockWidgetArea, d, target
|
||||||
|
)
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def delete_all(self):
|
||||||
|
"""Delete all docks and widgets."""
|
||||||
|
for dock in list(self.dock_manager.dockWidgets()):
|
||||||
|
self._delete_dock(dock)
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Workspace Management
|
||||||
|
################################################################################
|
||||||
|
@SafeProperty(bool)
|
||||||
|
def lock_workspace(self) -> bool:
|
||||||
|
"""
|
||||||
|
Get or set the lock state of the workspace.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the workspace is locked, False otherwise.
|
||||||
|
"""
|
||||||
|
return self._locked
|
||||||
|
|
||||||
|
@lock_workspace.setter
|
||||||
|
def lock_workspace(self, value: bool):
|
||||||
|
"""
|
||||||
|
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (bool): True to lock the workspace, False to unlock it.
|
||||||
|
"""
|
||||||
|
self._locked = value
|
||||||
|
self._apply_dock_lock(value)
|
||||||
|
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
|
||||||
|
self.toolbar.components.get_action("delete_workspace").action.setVisible(not value)
|
||||||
|
for dock in self.dock_list():
|
||||||
|
dock.setting_action.setVisible(not value)
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def save_profile(self, name: str | None = None):
|
||||||
|
"""
|
||||||
|
Save the current workspace profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
# Use the new SaveProfileDialog instead of QInputDialog
|
||||||
|
dialog = SaveProfileDialog(self)
|
||||||
|
if dialog.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
name = dialog.get_profile_name()
|
||||||
|
readonly = dialog.is_readonly()
|
||||||
|
|
||||||
|
# Check if profile already exists and is read-only
|
||||||
|
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||||
|
suggested_name = f"{name}_custom"
|
||||||
|
reply = QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Read-only Profile",
|
||||||
|
f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n"
|
||||||
|
f"Would you like to save it with a different name?\n"
|
||||||
|
f"Suggested name: '{suggested_name}'",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.Yes,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
# Show dialog again with suggested name pre-filled
|
||||||
|
dialog = SaveProfileDialog(self, suggested_name)
|
||||||
|
if dialog.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
name = dialog.get_profile_name()
|
||||||
|
readonly = dialog.is_readonly()
|
||||||
|
|
||||||
|
# Check again if the new name is also read-only (recursive protection)
|
||||||
|
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||||
|
return self.save_profile()
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# If name is provided directly, assume not read-only unless already exists
|
||||||
|
readonly = False
|
||||||
|
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Read-only Profile",
|
||||||
|
f"The profile '{name}' is marked as read-only and cannot be overwritten.",
|
||||||
|
QMessageBox.Ok,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display saving placeholder
|
||||||
|
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
|
workspace_combo.blockSignals(True)
|
||||||
|
workspace_combo.insertItem(0, f"{name}-saving")
|
||||||
|
workspace_combo.setCurrentIndex(0)
|
||||||
|
workspace_combo.blockSignals(False)
|
||||||
|
|
||||||
|
# Save the profile
|
||||||
|
settings = open_settings(name)
|
||||||
|
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
|
||||||
|
settings.setValue(
|
||||||
|
SETTINGS_KEYS["state"], b""
|
||||||
|
) # No QMainWindow state; placeholder for backward compat
|
||||||
|
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
|
||||||
|
self.dock_manager.addPerspective(name)
|
||||||
|
self.dock_manager.savePerspectives(settings)
|
||||||
|
self.state_manager.save_state(settings=settings)
|
||||||
|
write_manifest(settings, self.dock_list())
|
||||||
|
|
||||||
|
# Set read-only status if specified
|
||||||
|
if readonly:
|
||||||
|
set_profile_readonly(name, readonly)
|
||||||
|
|
||||||
|
settings.sync()
|
||||||
|
self._refresh_workspace_list()
|
||||||
|
workspace_combo.setCurrentText(name)
|
||||||
|
|
||||||
|
def load_profile(self, name: str | None = None):
|
||||||
|
"""
|
||||||
|
Load a workspace profile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
|
||||||
|
"""
|
||||||
|
# FIXME this has to be tweaked
|
||||||
|
if not name:
|
||||||
|
name, ok = QInputDialog.getText(
|
||||||
|
self, "Load Workspace", "Enter the name of the workspace profile to load:"
|
||||||
|
)
|
||||||
|
if not ok or not name:
|
||||||
|
return
|
||||||
|
settings = open_settings(name)
|
||||||
|
|
||||||
|
for item in read_manifest(settings):
|
||||||
|
obj_name = item["object_name"]
|
||||||
|
widget_class = item["widget_class"]
|
||||||
|
if obj_name not in self.widget_map():
|
||||||
|
w = widget_handler.create_widget(widget_type=widget_class, parent=self)
|
||||||
|
w.setObjectName(obj_name)
|
||||||
|
self._make_dock(
|
||||||
|
w,
|
||||||
|
closable=item["closable"],
|
||||||
|
floatable=item["floatable"],
|
||||||
|
movable=item["movable"],
|
||||||
|
area=QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||||
|
)
|
||||||
|
|
||||||
|
geom = settings.value(SETTINGS_KEYS["geom"])
|
||||||
|
if geom:
|
||||||
|
self.restoreGeometry(geom)
|
||||||
|
# No window state for QWidget-based host; keep for backwards compat read
|
||||||
|
# window_state = settings.value(SETTINGS_KEYS["state"]) # ignored
|
||||||
|
dock_state = settings.value(SETTINGS_KEYS["ads_state"])
|
||||||
|
if dock_state:
|
||||||
|
self.dock_manager.restoreState(dock_state)
|
||||||
|
self.dock_manager.loadPerspectives(settings)
|
||||||
|
self.state_manager.load_state(settings=settings)
|
||||||
|
self._set_editable(self._editable)
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def delete_profile(self):
|
||||||
|
"""
|
||||||
|
Delete the currently selected workspace profile file and refresh the combo list.
|
||||||
|
"""
|
||||||
|
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
|
name = combo.currentText()
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if profile is read-only
|
||||||
|
if is_profile_readonly(name):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Read-only Profile",
|
||||||
|
f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n"
|
||||||
|
f"Read-only profiles are protected from modification and deletion.",
|
||||||
|
QMessageBox.Ok,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Confirm deletion for regular profiles
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Delete Profile",
|
||||||
|
f"Are you sure you want to delete the profile '{name}'?\n\n"
|
||||||
|
f"This action cannot be undone.",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = profile_path(name)
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
self._refresh_workspace_list()
|
||||||
|
|
||||||
|
def _refresh_workspace_list(self):
|
||||||
|
"""
|
||||||
|
Populate the workspace combo box with all saved profile names (without .ini).
|
||||||
|
"""
|
||||||
|
combo = self.toolbar.components.get_action("workspace_combo").widget
|
||||||
|
if hasattr(combo, "refresh_profiles"):
|
||||||
|
combo.refresh_profiles()
|
||||||
|
else:
|
||||||
|
# Fallback for regular QComboBox
|
||||||
|
combo.blockSignals(True)
|
||||||
|
combo.clear()
|
||||||
|
combo.addItems(list_profiles())
|
||||||
|
combo.blockSignals(False)
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
# Mode Switching
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
@SafeProperty(str)
|
||||||
|
def mode(self) -> str:
|
||||||
|
return self._mode
|
||||||
|
|
||||||
|
@mode.setter
|
||||||
|
def mode(self, new_mode: str):
|
||||||
|
if new_mode not in ["plot", "device", "utils", "developer", "user"]:
|
||||||
|
raise ValueError(f"Invalid mode: {new_mode}")
|
||||||
|
self._mode = new_mode
|
||||||
|
self.mode_changed.emit(new_mode)
|
||||||
|
|
||||||
|
# Update toolbar visibility based on mode
|
||||||
|
if new_mode == "user":
|
||||||
|
# User mode: show only essential tools
|
||||||
|
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||||
|
elif new_mode == "developer":
|
||||||
|
# Developer mode: show all tools (use menu bundles)
|
||||||
|
self.toolbar.show_bundles(
|
||||||
|
[
|
||||||
|
"menu_plots",
|
||||||
|
"menu_devices",
|
||||||
|
"menu_utils",
|
||||||
|
"spacer_bundle",
|
||||||
|
"workspace",
|
||||||
|
"dock_actions",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif new_mode in ["plot", "device", "utils"]:
|
||||||
|
# Specific modes: show flat toolbar for that category
|
||||||
|
bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils"
|
||||||
|
self.toolbar.show_bundles([bundle_name])
|
||||||
|
# self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"])
|
||||||
|
else:
|
||||||
|
# Fallback to user mode
|
||||||
|
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""
|
||||||
|
Cleanup the dock area.
|
||||||
|
"""
|
||||||
|
self.delete_all()
|
||||||
|
self.dark_mode_button.close()
|
||||||
|
self.dark_mode_button.deleteLater()
|
||||||
|
self.toolbar.cleanup()
|
||||||
|
super().cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
dispatcher = BECDispatcher(gui_id="ads")
|
||||||
|
window = BECMainWindowNoRPC()
|
||||||
|
ads = AdvancedDockArea(mode="developer", root_widget=True)
|
||||||
|
window.setCentralWidget(ads)
|
||||||
|
window.show()
|
||||||
|
window.resize(800, 600)
|
||||||
|
|
||||||
|
sys.exit(app.exec())
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from PySide6QtAds import CDockWidget
|
||||||
|
from qtpy.QtCore import QSettings
|
||||||
|
|
||||||
|
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
_DEFAULT_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "default")
|
||||||
|
_USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user")
|
||||||
|
|
||||||
|
|
||||||
|
def profiles_dir() -> str:
|
||||||
|
path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR)
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def profile_path(name: str) -> str:
|
||||||
|
return os.path.join(profiles_dir(), f"{name}.ini")
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS_KEYS = {
|
||||||
|
"geom": "mainWindow/Geometry",
|
||||||
|
"state": "mainWindow/State",
|
||||||
|
"ads_state": "mainWindow/DockingState",
|
||||||
|
"manifest": "manifest/widgets",
|
||||||
|
"readonly": "profile/readonly",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles() -> list[str]:
|
||||||
|
return sorted(os.path.splitext(f)[0] for f in os.listdir(profiles_dir()) if f.endswith(".ini"))
|
||||||
|
|
||||||
|
|
||||||
|
def is_profile_readonly(name: str) -> bool:
|
||||||
|
"""Check if a profile is marked as read-only."""
|
||||||
|
settings = open_settings(name)
|
||||||
|
return settings.value(SETTINGS_KEYS["readonly"], False, type=bool)
|
||||||
|
|
||||||
|
|
||||||
|
def set_profile_readonly(name: str, readonly: bool) -> None:
|
||||||
|
"""Set the read-only status of a profile."""
|
||||||
|
settings = open_settings(name)
|
||||||
|
settings.setValue(SETTINGS_KEYS["readonly"], readonly)
|
||||||
|
settings.sync()
|
||||||
|
|
||||||
|
|
||||||
|
def open_settings(name: str) -> QSettings:
|
||||||
|
return QSettings(profile_path(name), QSettings.IniFormat)
|
||||||
|
|
||||||
|
|
||||||
|
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
|
||||||
|
settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks))
|
||||||
|
for i, dock in enumerate(docks):
|
||||||
|
settings.setArrayIndex(i)
|
||||||
|
w = dock.widget()
|
||||||
|
settings.setValue("object_name", w.objectName())
|
||||||
|
settings.setValue("widget_class", w.__class__.__name__)
|
||||||
|
settings.setValue("closable", getattr(dock, "_default_closable", True))
|
||||||
|
settings.setValue("floatable", getattr(dock, "_default_floatable", True))
|
||||||
|
settings.setValue("movable", getattr(dock, "_default_movable", True))
|
||||||
|
settings.endArray()
|
||||||
|
|
||||||
|
|
||||||
|
def read_manifest(settings: QSettings) -> list[dict]:
|
||||||
|
items: list[dict] = []
|
||||||
|
count = settings.beginReadArray(SETTINGS_KEYS["manifest"])
|
||||||
|
for i in range(count):
|
||||||
|
settings.setArrayIndex(i)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"object_name": settings.value("object_name"),
|
||||||
|
"widget_class": settings.value("widget_class"),
|
||||||
|
"closable": settings.value("closable", type=bool),
|
||||||
|
"floatable": settings.value("floatable", type=bool),
|
||||||
|
"movable": settings.value("movable", type=bool),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
settings.endArray()
|
||||||
|
return items
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from bec_qthemes import material_icon
|
||||||
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget
|
||||||
|
|
||||||
|
from bec_widgets import SafeSlot
|
||||||
|
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
|
||||||
|
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
|
||||||
|
from bec_widgets.utils.toolbars.connections import BundleConnection
|
||||||
|
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
|
||||||
|
is_profile_readonly,
|
||||||
|
list_profiles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileComboBox(QComboBox):
|
||||||
|
"""Custom combobox that displays icons for read-only profiles."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
|
||||||
|
def refresh_profiles(self):
|
||||||
|
"""Refresh the profile list with appropriate icons."""
|
||||||
|
|
||||||
|
current_text = self.currentText()
|
||||||
|
self.blockSignals(True)
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
lock_icon = material_icon("edit_off", size=(16, 16), convert_to_pixmap=False)
|
||||||
|
|
||||||
|
for profile in list_profiles():
|
||||||
|
if is_profile_readonly(profile):
|
||||||
|
self.addItem(lock_icon, f"{profile}")
|
||||||
|
# Set tooltip for read-only profiles
|
||||||
|
self.setItemData(self.count() - 1, "Read-only profile", Qt.ToolTipRole)
|
||||||
|
else:
|
||||||
|
self.addItem(profile)
|
||||||
|
|
||||||
|
# Restore selection if possible
|
||||||
|
index = self.findText(current_text)
|
||||||
|
if index >= 0:
|
||||||
|
self.setCurrentIndex(index)
|
||||||
|
|
||||||
|
self.blockSignals(False)
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
|
||||||
|
"""
|
||||||
|
Creates a workspace toolbar bundle for AdvancedDockArea.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
components (ToolbarComponents): The components to be added to the bundle.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ToolbarBundle: The workspace toolbar bundle.
|
||||||
|
"""
|
||||||
|
# Lock icon action
|
||||||
|
components.add_safe(
|
||||||
|
"lock",
|
||||||
|
MaterialIconAction(
|
||||||
|
icon_name="lock_open_right",
|
||||||
|
tooltip="Lock Workspace",
|
||||||
|
checkable=True,
|
||||||
|
parent=components.toolbar,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Workspace combo
|
||||||
|
combo = ProfileComboBox(parent=components.toolbar)
|
||||||
|
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
|
||||||
|
|
||||||
|
# Save the current workspace icon
|
||||||
|
components.add_safe(
|
||||||
|
"save_workspace",
|
||||||
|
MaterialIconAction(
|
||||||
|
icon_name="save",
|
||||||
|
tooltip="Save Current Workspace",
|
||||||
|
checkable=False,
|
||||||
|
parent=components.toolbar,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Delete workspace icon
|
||||||
|
components.add_safe(
|
||||||
|
"refresh_workspace",
|
||||||
|
MaterialIconAction(
|
||||||
|
icon_name="refresh",
|
||||||
|
tooltip="Refresh Current Workspace",
|
||||||
|
checkable=False,
|
||||||
|
parent=components.toolbar,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Delete workspace icon
|
||||||
|
components.add_safe(
|
||||||
|
"delete_workspace",
|
||||||
|
MaterialIconAction(
|
||||||
|
icon_name="delete",
|
||||||
|
tooltip="Delete Current Workspace",
|
||||||
|
checkable=False,
|
||||||
|
parent=components.toolbar,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
bundle = ToolbarBundle("workspace", components)
|
||||||
|
bundle.add_action("lock")
|
||||||
|
bundle.add_action("workspace_combo")
|
||||||
|
bundle.add_action("save_workspace")
|
||||||
|
bundle.add_action("refresh_workspace")
|
||||||
|
bundle.add_action("delete_workspace")
|
||||||
|
return bundle
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceConnection(BundleConnection):
|
||||||
|
"""
|
||||||
|
Connection class for workspace actions in AdvancedDockArea.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, components: ToolbarComponents, target_widget=None):
|
||||||
|
super().__init__(parent=components.toolbar)
|
||||||
|
self.bundle_name = "workspace"
|
||||||
|
self.components = components
|
||||||
|
self.target_widget = target_widget
|
||||||
|
if not hasattr(self.target_widget, "lock_workspace"):
|
||||||
|
raise AttributeError("Target widget must implement 'lock_workspace'.")
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
self._connected = True
|
||||||
|
# Connect the action to the target widget's method
|
||||||
|
self.components.get_action("lock").action.toggled.connect(self._lock_workspace)
|
||||||
|
self.components.get_action("save_workspace").action.triggered.connect(
|
||||||
|
self.target_widget.save_profile
|
||||||
|
)
|
||||||
|
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
|
||||||
|
self.target_widget.load_profile
|
||||||
|
)
|
||||||
|
self.components.get_action("refresh_workspace").action.triggered.connect(
|
||||||
|
self._refresh_workspace
|
||||||
|
)
|
||||||
|
self.components.get_action("delete_workspace").action.triggered.connect(
|
||||||
|
self.target_widget.delete_profile
|
||||||
|
)
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
if not self._connected:
|
||||||
|
return
|
||||||
|
# Disconnect the action from the target widget's method
|
||||||
|
self.components.get_action("lock").action.toggled.disconnect(self._lock_workspace)
|
||||||
|
self.components.get_action("save_workspace").action.triggered.disconnect(
|
||||||
|
self.target_widget.save_profile
|
||||||
|
)
|
||||||
|
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
|
||||||
|
self.target_widget.load_profile
|
||||||
|
)
|
||||||
|
self.components.get_action("refresh_workspace").action.triggered.disconnect(
|
||||||
|
self._refresh_workspace
|
||||||
|
)
|
||||||
|
self.components.get_action("delete_workspace").action.triggered.disconnect(
|
||||||
|
self.target_widget.delete_profile
|
||||||
|
)
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
@SafeSlot(bool)
|
||||||
|
def _lock_workspace(self, value: bool):
|
||||||
|
"""
|
||||||
|
Switches the workspace lock state and change the icon accordingly.
|
||||||
|
"""
|
||||||
|
setattr(self.target_widget, "lock_workspace", value)
|
||||||
|
self.components.get_action("lock").action.setChecked(value)
|
||||||
|
icon = material_icon(
|
||||||
|
"lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False
|
||||||
|
)
|
||||||
|
self.components.get_action("lock").action.setIcon(icon)
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def _refresh_workspace(self):
|
||||||
|
"""
|
||||||
|
Refreshes the current workspace.
|
||||||
|
"""
|
||||||
|
combo = self.components.get_action("workspace_combo").widget
|
||||||
|
current_workspace = combo.currentText()
|
||||||
|
self.target_widget.load_profile(current_workspace)
|
||||||
@@ -616,10 +616,10 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
set_theme("auto")
|
apply_theme("dark")
|
||||||
dock_area = BECDockArea()
|
dock_area = BECDockArea()
|
||||||
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
|
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
|
||||||
dock_1.new(widget="DarkModeButton")
|
dock_1.new(widget="DarkModeButton")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
|
|||||||
import bec_widgets
|
import bec_widgets
|
||||||
from bec_widgets.utils import UILoader
|
from bec_widgets.utils import UILoader
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import apply_theme, set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
|
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
|
||||||
@@ -357,7 +357,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
|
|
||||||
########################################
|
########################################
|
||||||
# Theme menu
|
# Theme menu
|
||||||
theme_menu = menu_bar.addMenu("Theme")
|
theme_menu = menu_bar.addMenu("View")
|
||||||
|
|
||||||
theme_group = QActionGroup(self)
|
theme_group = QActionGroup(self)
|
||||||
light_theme_action = QAction("Light Theme", self, checkable=True)
|
light_theme_action = QAction("Light Theme", self, checkable=True)
|
||||||
@@ -374,10 +374,11 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
|
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
|
||||||
|
|
||||||
# Set the default theme
|
# Set the default theme
|
||||||
theme = self.app.theme.theme
|
if hasattr(self.app, "theme") and self.app.theme:
|
||||||
if theme == "light":
|
theme_name = self.app.theme.theme.lower()
|
||||||
|
if "light" in theme_name:
|
||||||
light_theme_action.setChecked(True)
|
light_theme_action.setChecked(True)
|
||||||
elif theme == "dark":
|
elif "dark" in theme_name:
|
||||||
dark_theme_action.setChecked(True)
|
dark_theme_action.setChecked(True)
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
@@ -448,7 +449,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
|||||||
Args:
|
Args:
|
||||||
theme(str): Either "light" or "dark".
|
theme(str): Either "light" or "dark".
|
||||||
"""
|
"""
|
||||||
set_theme(theme) # emits theme_updated and applies palette globally
|
apply_theme(theme) # emits theme_updated and applies palette globally
|
||||||
|
|
||||||
def event(self, event):
|
def event(self, event):
|
||||||
if event.type() == QEvent.Type.StatusTip:
|
if event.type() == QEvent.Type.StatusTip:
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ class AbortButton(BECWidget, QWidget):
|
|||||||
else:
|
else:
|
||||||
self.button = QPushButton()
|
self.button = QPushButton()
|
||||||
self.button.setText("Abort")
|
self.button.setText("Abort")
|
||||||
self.button.setStyleSheet(
|
|
||||||
"background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
|
|
||||||
)
|
|
||||||
self.button.clicked.connect(self.abort_scan)
|
self.button.clicked.connect(self.abort_scan)
|
||||||
|
|
||||||
self.layout.addWidget(self.button)
|
self.layout.addWidget(self.button)
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ class StopButton(BECWidget, QWidget):
|
|||||||
self.button = QPushButton()
|
self.button = QPushButton()
|
||||||
self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||||
self.button.setText("Stop")
|
self.button.setText("Stop")
|
||||||
self.button.setStyleSheet(
|
self.button.setProperty("variant", "danger")
|
||||||
f"background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;"
|
|
||||||
)
|
|
||||||
self.button.clicked.connect(self.stop_scan)
|
self.button.clicked.connect(self.stop_scan)
|
||||||
|
|
||||||
self.layout.addWidget(self.button)
|
self.layout.addWidget(self.button)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from qtpy.QtGui import QDoubleValidator
|
|||||||
from qtpy.QtWidgets import QDoubleSpinBox
|
from qtpy.QtWidgets import QDoubleSpinBox
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
from bec_widgets.utils import UILoader
|
||||||
from bec_widgets.utils.colors import get_accent_colors, set_theme
|
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||||
@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
|
|||||||
PLUGIN = True
|
PLUGIN = True
|
||||||
RPC = True
|
RPC = True
|
||||||
|
|
||||||
USER_ACCESS = ["set_positioner", "screenshot"]
|
USER_ACCESS = ["set_positioner", "attach", "detach", "screenshot"]
|
||||||
device_changed = Signal(str, str)
|
device_changed = Signal(str, str)
|
||||||
# Signal emitted to inform listeners about a position update
|
# Signal emitted to inform listeners about a position update
|
||||||
position_update = Signal(float)
|
position_update = Signal(float)
|
||||||
@@ -259,7 +259,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = PositionerBox(device="bpm4i")
|
widget = PositionerBox(device="bpm4i")
|
||||||
|
|
||||||
widget.show()
|
widget.show()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from qtpy.QtGui import QDoubleValidator
|
|||||||
from qtpy.QtWidgets import QDoubleSpinBox
|
from qtpy.QtWidgets import QDoubleSpinBox
|
||||||
|
|
||||||
from bec_widgets.utils import UILoader
|
from bec_widgets.utils import UILoader
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
|
||||||
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
|
||||||
@@ -34,7 +34,7 @@ class PositionerBox2D(PositionerBoxBase):
|
|||||||
|
|
||||||
PLUGIN = True
|
PLUGIN = True
|
||||||
RPC = True
|
RPC = True
|
||||||
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "screenshot"]
|
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "attach", "detach", "screenshot"]
|
||||||
|
|
||||||
device_changed_hor = Signal(str, str)
|
device_changed_hor = Signal(str, str)
|
||||||
device_changed_ver = Signal(str, str)
|
device_changed_ver = Signal(str, str)
|
||||||
@@ -478,7 +478,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = PositionerBox2D()
|
widget = PositionerBox2D()
|
||||||
|
|
||||||
widget.show()
|
widget.show()
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class PositionerGroup(BECWidget, QWidget):
|
|||||||
|
|
||||||
PLUGIN = True
|
PLUGIN = True
|
||||||
ICON_NAME = "grid_view"
|
ICON_NAME = "grid_view"
|
||||||
USER_ACCESS = ["set_positioners"]
|
USER_ACCESS = ["set_positioners", "attach", "detach", "screenshot"]
|
||||||
|
|
||||||
# Signal emitted to inform listeners about a position update of the first positioner
|
# Signal emitted to inform listeners about a position update of the first positioner
|
||||||
position_update = Signal(float)
|
position_update = Signal(float)
|
||||||
|
|||||||
@@ -147,24 +147,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
|||||||
dev_name = self.currentText()
|
dev_name = self.currentText()
|
||||||
return self.get_device_object(dev_name)
|
return self.get_device_object(dev_name)
|
||||||
|
|
||||||
def paintEvent(self, event: QPaintEvent) -> None:
|
|
||||||
"""Extend the paint event to set the border color based on the validity of the input.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event (PySide6.QtGui.QPaintEvent) : Paint event.
|
|
||||||
"""
|
|
||||||
# logger.info(f"Received paint event: {event} in {self.__class__}")
|
|
||||||
super().paintEvent(event)
|
|
||||||
|
|
||||||
if self._is_valid_input is False and self.isEnabled() is True:
|
|
||||||
painter = QPainter(self)
|
|
||||||
pen = QPen()
|
|
||||||
pen.setWidth(2)
|
|
||||||
pen.setColor(self._accent_colors.emergency)
|
|
||||||
painter.setPen(pen)
|
|
||||||
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
|
|
||||||
painter.end()
|
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
def check_validity(self, input_text: str) -> None:
|
def check_validity(self, input_text: str) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -173,10 +155,12 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
|
|||||||
if self.validate_device(input_text) is True:
|
if self.validate_device(input_text) is True:
|
||||||
self._is_valid_input = True
|
self._is_valid_input = True
|
||||||
self.device_selected.emit(input_text)
|
self.device_selected.emit(input_text)
|
||||||
|
self.setStyleSheet("border: 1px solid transparent;")
|
||||||
else:
|
else:
|
||||||
self._is_valid_input = False
|
self._is_valid_input = False
|
||||||
self.device_reset.emit()
|
self.device_reset.emit()
|
||||||
self.update()
|
if self.isEnabled():
|
||||||
|
self.setStyleSheet("border: 1px solid red;")
|
||||||
|
|
||||||
def validate_device(self, device: str) -> bool: # type: ignore[override]
|
def validate_device(self, device: str) -> bool: # type: ignore[override]
|
||||||
"""
|
"""
|
||||||
@@ -202,10 +186,10 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
widget.setFixedSize(200, 200)
|
widget.setFixedSize(200, 200)
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|||||||
@@ -175,13 +175,13 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
|
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
|
||||||
SignalComboBox,
|
SignalComboBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
widget.setFixedSize(200, 200)
|
widget.setFixedSize(200, 200)
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|||||||
@@ -179,10 +179,10 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
widget.setFixedSize(200, 200)
|
widget.setFixedSize(200, 200)
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|||||||
@@ -147,13 +147,13 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
|
||||||
DeviceComboBox,
|
DeviceComboBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
widget.setFixedSize(200, 200)
|
widget.setFixedSize(200, 200)
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .device_table_view import DeviceTableView
|
||||||
|
from .dm_config_view import DMConfigView
|
||||||
|
from .dm_docstring_view import DocstringView
|
||||||
|
from .dm_ophyd_test import DMOphydTest
|
||||||
|
|||||||
@@ -3,16 +3,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import time
|
||||||
|
|
||||||
from bec_lib.logger import bec_logger
|
from bec_lib.logger import bec_logger
|
||||||
from bec_qthemes import material_icon
|
from bec_qthemes import material_icon
|
||||||
from qtpy import QtCore, QtGui, QtWidgets
|
from qtpy import QtCore, QtGui, QtWidgets
|
||||||
from thefuzz import fuzz
|
from thefuzz import fuzz
|
||||||
|
|
||||||
|
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import get_accent_colors
|
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
|
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus
|
||||||
|
|
||||||
logger = bec_logger.logger
|
logger = bec_logger.logger
|
||||||
|
|
||||||
@@ -23,34 +25,32 @@ FUZZY_SEARCH_THRESHOLD = 80
|
|||||||
class DictToolTipDelegate(QtWidgets.QStyledItemDelegate):
|
class DictToolTipDelegate(QtWidgets.QStyledItemDelegate):
|
||||||
"""Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip."""
|
"""Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip."""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def dict_to_str(d: dict) -> str:
|
|
||||||
"""Convert a dictionary to a formatted string."""
|
|
||||||
return json.dumps(d, indent=4)
|
|
||||||
|
|
||||||
def helpEvent(self, event, view, option, index):
|
def helpEvent(self, event, view, option, index):
|
||||||
"""Override to show tooltip when hovering."""
|
"""Override to show tooltip when hovering."""
|
||||||
if event.type() != QtCore.QEvent.ToolTip:
|
if event.type() != QtCore.QEvent.ToolTip:
|
||||||
return super().helpEvent(event, view, option, index)
|
return super().helpEvent(event, view, option, index)
|
||||||
model: DeviceFilterProxyModel = index.model()
|
model: DeviceFilterProxyModel = index.model()
|
||||||
model_index = model.mapToSource(index)
|
model_index = model.mapToSource(index)
|
||||||
row_dict = model.sourceModel().row_data(model_index)
|
row_dict = model.sourceModel().get_row_data(model_index)
|
||||||
row_dict.pop("description", None)
|
description = row_dict.get("description", "")
|
||||||
QtWidgets.QToolTip.showText(event.globalPos(), self.dict_to_str(row_dict), view)
|
QtWidgets.QToolTip.showText(event.globalPos(), description, view)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
class CenterCheckBoxDelegate(DictToolTipDelegate):
|
class CenterCheckBoxDelegate(DictToolTipDelegate):
|
||||||
"""Custom checkbox delegate to center checkboxes in table cells."""
|
"""Custom checkbox delegate to center checkboxes in table cells."""
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None, colors=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
colors = get_accent_colors()
|
self._colors = colors if colors else get_accent_colors()
|
||||||
self._icon_checked = material_icon(
|
self._icon_checked = material_icon(
|
||||||
"check_box", size=QtCore.QSize(16, 16), color=colors.default
|
"check_box", size=QtCore.QSize(16, 16), color=self._colors.default, filled=True
|
||||||
)
|
)
|
||||||
self._icon_unchecked = material_icon(
|
self._icon_unchecked = material_icon(
|
||||||
"check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default
|
"check_box_outline_blank",
|
||||||
|
size=QtCore.QSize(16, 16),
|
||||||
|
color=self._colors.default,
|
||||||
|
filled=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def apply_theme(self, theme: str | None = None):
|
def apply_theme(self, theme: str | None = None):
|
||||||
@@ -81,9 +81,51 @@ class CenterCheckBoxDelegate(DictToolTipDelegate):
|
|||||||
return model.setData(index, new_state, QtCore.Qt.CheckStateRole)
|
return model.setData(index, new_state, QtCore.Qt.CheckStateRole)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceValidatedDelegate(DictToolTipDelegate):
|
||||||
|
"""Custom delegate for displaying validated device configurations."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None, colors=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._colors = colors if colors else get_accent_colors()
|
||||||
|
self._icons = {
|
||||||
|
ValidationStatus.PENDING: material_icon(
|
||||||
|
icon_name="circle", size=(12, 12), color=self._colors.default, filled=True
|
||||||
|
),
|
||||||
|
ValidationStatus.VALID: material_icon(
|
||||||
|
icon_name="circle", size=(12, 12), color=self._colors.success, filled=True
|
||||||
|
),
|
||||||
|
ValidationStatus.FAILED: material_icon(
|
||||||
|
icon_name="circle", size=(12, 12), color=self._colors.emergency, filled=True
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def apply_theme(self, theme: str | None = None):
|
||||||
|
colors = get_accent_colors()
|
||||||
|
for status, icon in self._icons.items():
|
||||||
|
icon.setColor(colors[status])
|
||||||
|
|
||||||
|
def paint(self, painter, option, index):
|
||||||
|
status = index.model().data(index, QtCore.Qt.DisplayRole)
|
||||||
|
if status is None:
|
||||||
|
return super().paint(painter, option, index)
|
||||||
|
|
||||||
|
pixmap = self._icons.get(status)
|
||||||
|
if pixmap:
|
||||||
|
rect = option.rect
|
||||||
|
pix_rect = pixmap.rect()
|
||||||
|
pix_rect.moveCenter(rect.center())
|
||||||
|
painter.drawPixmap(pix_rect.topLeft(), pixmap)
|
||||||
|
|
||||||
|
super().paint(painter, option, index)
|
||||||
|
|
||||||
|
|
||||||
class WrappingTextDelegate(DictToolTipDelegate):
|
class WrappingTextDelegate(DictToolTipDelegate):
|
||||||
"""Custom delegate for wrapping text in table cells."""
|
"""Custom delegate for wrapping text in table cells."""
|
||||||
|
|
||||||
|
def __init__(self, table: BECTableView, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._table = table
|
||||||
|
|
||||||
def paint(self, painter, option, index):
|
def paint(self, painter, option, index):
|
||||||
text = index.model().data(index, QtCore.Qt.DisplayRole)
|
text = index.model().data(index, QtCore.Qt.DisplayRole)
|
||||||
if not text:
|
if not text:
|
||||||
@@ -97,12 +139,14 @@ class WrappingTextDelegate(DictToolTipDelegate):
|
|||||||
|
|
||||||
def sizeHint(self, option, index):
|
def sizeHint(self, option, index):
|
||||||
text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "")
|
text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "")
|
||||||
# if not text:
|
column_width = self._table.columnWidth(index.column()) - 8 # -4 & 4
|
||||||
# return super().sizeHint(option, index)
|
|
||||||
|
|
||||||
# Use the actual column width
|
# Avoid pathological heights for too-narrow columns
|
||||||
table = index.model().parent() # or store reference to QTableView
|
min_width = option.fontMetrics.averageCharWidth() * 4
|
||||||
column_width = table.columnWidth(index.column()) # - 8
|
if column_width < min_width:
|
||||||
|
fm = QtGui.QFontMetrics(option.font)
|
||||||
|
elided = fm.elidedText(text, QtCore.Qt.ElideRight, column_width)
|
||||||
|
return QtCore.QSize(column_width, fm.height() + 4)
|
||||||
|
|
||||||
doc = QtGui.QTextDocument()
|
doc = QtGui.QTextDocument()
|
||||||
doc.setDefaultFont(option.font)
|
doc.setDefaultFont(option.font)
|
||||||
@@ -110,8 +154,25 @@ class WrappingTextDelegate(DictToolTipDelegate):
|
|||||||
doc.setPlainText(text)
|
doc.setPlainText(text)
|
||||||
|
|
||||||
layout_height = doc.documentLayout().documentSize().height()
|
layout_height = doc.documentLayout().documentSize().height()
|
||||||
height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off
|
return QtCore.QSize(column_width, int(layout_height) + 4)
|
||||||
return QtCore.QSize(column_width, height)
|
|
||||||
|
# def sizeHint(self, option, index):
|
||||||
|
# text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "")
|
||||||
|
# # if not text:
|
||||||
|
# # return super().sizeHint(option, index)
|
||||||
|
|
||||||
|
# # Use the actual column width
|
||||||
|
# table = index.model().parent() # or store reference to QTableView
|
||||||
|
# column_width = table.columnWidth(index.column()) # - 8
|
||||||
|
|
||||||
|
# doc = QtGui.QTextDocument()
|
||||||
|
# doc.setDefaultFont(option.font)
|
||||||
|
# doc.setTextWidth(column_width)
|
||||||
|
# doc.setPlainText(text)
|
||||||
|
|
||||||
|
# layout_height = doc.documentLayout().documentSize().height()
|
||||||
|
# height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off
|
||||||
|
# return QtCore.QSize(column_width, height)
|
||||||
|
|
||||||
|
|
||||||
class DeviceTableModel(QtCore.QAbstractTableModel):
|
class DeviceTableModel(QtCore.QAbstractTableModel):
|
||||||
@@ -121,17 +182,22 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
|||||||
Sort logic is implemented directly on the data of the table view.
|
Sort logic is implemented directly on the data of the table view.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, device_config: list[dict] | None = None, parent=None):
|
device_configs_added = QtCore.Signal(dict) # Dict[str, dict] of configs that were added
|
||||||
|
devices_removed = QtCore.Signal(list) # List of strings with device names that were removed
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._device_config = device_config or []
|
self._device_config: dict[str, dict] = {}
|
||||||
|
self._list_items: list[dict] = []
|
||||||
|
self._validation_status: dict[str, ValidationStatus] = {}
|
||||||
self.headers = [
|
self.headers = [
|
||||||
|
"",
|
||||||
"name",
|
"name",
|
||||||
"deviceClass",
|
"deviceClass",
|
||||||
"readoutPriority",
|
"readoutPriority",
|
||||||
|
"deviceTags",
|
||||||
"enabled",
|
"enabled",
|
||||||
"readOnly",
|
"readOnly",
|
||||||
"deviceTags",
|
|
||||||
"description",
|
|
||||||
]
|
]
|
||||||
self._checkable_columns_enabled = {"enabled": True, "readOnly": True}
|
self._checkable_columns_enabled = {"enabled": True, "readOnly": True}
|
||||||
|
|
||||||
@@ -140,7 +206,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
|||||||
###############################################
|
###############################################
|
||||||
|
|
||||||
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
|
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
|
||||||
return len(self._device_config)
|
return len(self._list_items)
|
||||||
|
|
||||||
def columnCount(self, parent=QtCore.QModelIndex()) -> int:
|
def columnCount(self, parent=QtCore.QModelIndex()) -> int:
|
||||||
return len(self.headers)
|
return len(self.headers)
|
||||||
@@ -150,25 +216,32 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
|||||||
return self.headers[section]
|
return self.headers[section]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def row_data(self, index: QtCore.QModelIndex) -> dict:
|
def get_row_data(self, index: QtCore.QModelIndex) -> dict:
|
||||||
"""Return the row data for the given index."""
|
"""Return the row data for the given index."""
|
||||||
if not index.isValid():
|
if not index.isValid():
|
||||||
return {}
|
return {}
|
||||||
return copy.deepcopy(self._device_config[index.row()])
|
return copy.deepcopy(self._list_items[index.row()])
|
||||||
|
|
||||||
def data(self, index, role=QtCore.Qt.DisplayRole):
|
def data(self, index, role=QtCore.Qt.DisplayRole):
|
||||||
"""Return data for the given index and role."""
|
"""Return data for the given index and role."""
|
||||||
if not index.isValid():
|
if not index.isValid():
|
||||||
return None
|
return None
|
||||||
row, col = index.row(), index.column()
|
row, col = index.row(), index.column()
|
||||||
|
|
||||||
|
if col == 0 and role == QtCore.Qt.DisplayRole: # QtCore.Qt.DisplayRole:
|
||||||
|
dev_name = self._list_items[row].get("name", "")
|
||||||
|
return self._validation_status.get(dev_name, ValidationStatus.PENDING)
|
||||||
|
|
||||||
key = self.headers[col]
|
key = self.headers[col]
|
||||||
value = self._device_config[row].get(key)
|
value = self._list_items[row].get(key)
|
||||||
|
|
||||||
if role == QtCore.Qt.DisplayRole:
|
if role == QtCore.Qt.DisplayRole:
|
||||||
if key in ("enabled", "readOnly"):
|
if key in ("enabled", "readOnly"):
|
||||||
return bool(value)
|
return bool(value)
|
||||||
if key == "deviceTags":
|
if key == "deviceTags":
|
||||||
return ", ".join(str(tag) for tag in value) if value else ""
|
return ", ".join(str(tag) for tag in value) if value else ""
|
||||||
|
if key == "deviceClass":
|
||||||
|
return str(value).split(".")[-1]
|
||||||
return str(value) if value is not None else ""
|
return str(value) if value is not None else ""
|
||||||
if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"):
|
if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"):
|
||||||
return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked
|
return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked
|
||||||
@@ -215,7 +288,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
|||||||
if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole:
|
if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole:
|
||||||
if not self._checkable_columns_enabled.get(key, True):
|
if not self._checkable_columns_enabled.get(key, True):
|
||||||
return False # ignore changes if column is disabled
|
return False # ignore changes if column is disabled
|
||||||
self._device_config[row][key] = value == QtCore.Qt.Checked
|
self._list_items[row][key] = value == QtCore.Qt.Checked
|
||||||
self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole])
|
self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole])
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -224,87 +297,115 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
|||||||
############ Public methods ########
|
############ Public methods ########
|
||||||
####################################
|
####################################
|
||||||
|
|
||||||
def get_device_config(self) -> list[dict]:
|
def get_device_config(self) -> dict[str, dict]:
|
||||||
"""Return the current device config (with checkbox updates applied)."""
|
"""Method to get the device configuration."""
|
||||||
return self._device_config
|
return self._device_config
|
||||||
|
|
||||||
def set_checkbox_enabled(self, column_name: str, enabled: bool):
|
def add_device_configs(self, device_configs: dict[str, dict]):
|
||||||
"""
|
"""
|
||||||
Enable/Disable the checkbox column.
|
Add devices to the model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
column_name (str): The name of the column to modify.
|
device_configs (dict[str, dict]): A dictionary of device configurations to add.
|
||||||
enabled (bool): Whether the checkbox should be enabled or disabled.
|
|
||||||
"""
|
"""
|
||||||
if column_name in self._checkable_columns_enabled:
|
already_in_list = []
|
||||||
self._checkable_columns_enabled[column_name] = enabled
|
for k, cfg in device_configs.items():
|
||||||
col = self.headers.index(column_name)
|
if k in self._device_config:
|
||||||
top_left = self.index(0, col)
|
logger.warning(f"Device {k} already exists in the model.")
|
||||||
bottom_right = self.index(self.rowCount() - 1, col)
|
already_in_list.append(k)
|
||||||
self.dataChanged.emit(
|
continue
|
||||||
top_left, bottom_right, [QtCore.Qt.CheckStateRole, QtCore.Qt.DisplayRole]
|
self._device_config[k] = cfg
|
||||||
)
|
new_list_cfg = copy.deepcopy(cfg)
|
||||||
|
new_list_cfg["name"] = k
|
||||||
|
row = len(self._list_items)
|
||||||
|
self.beginInsertRows(QtCore.QModelIndex(), row, row)
|
||||||
|
self._list_items.append(new_list_cfg)
|
||||||
|
self.endInsertRows()
|
||||||
|
for k in already_in_list:
|
||||||
|
device_configs.pop(k)
|
||||||
|
self.device_configs_added.emit(device_configs)
|
||||||
|
|
||||||
def set_device_config(self, device_config: list[dict]):
|
def set_device_config(self, device_configs: dict[str, dict]):
|
||||||
"""
|
"""
|
||||||
Replace the device config.
|
Replace the device config.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_config (list[dict]): The new device config to set.
|
device_config (dict[str, dict]): The new device config to set.
|
||||||
"""
|
"""
|
||||||
|
diff_names = set(device_configs.keys()) - set(self._device_config.keys())
|
||||||
self.beginResetModel()
|
self.beginResetModel()
|
||||||
self._device_config = list(device_config)
|
self._device_config.clear()
|
||||||
|
self._list_items.clear()
|
||||||
|
for k, cfg in device_configs.items():
|
||||||
|
self._device_config[k] = cfg
|
||||||
|
new_list_cfg = copy.deepcopy(cfg)
|
||||||
|
new_list_cfg["name"] = k
|
||||||
|
self._list_items.append(new_list_cfg)
|
||||||
self.endResetModel()
|
self.endResetModel()
|
||||||
|
self.devices_removed.emit(diff_names)
|
||||||
|
self.device_configs_added.emit(device_configs)
|
||||||
|
|
||||||
@SafeSlot(dict)
|
def remove_device_configs(self, device_configs: dict[str, dict]):
|
||||||
def add_device(self, device: dict):
|
|
||||||
"""
|
"""
|
||||||
Add an extra device to the device config at the bottom.
|
Remove devices from the model.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device (dict): The device configuration to add.
|
device_configs (dict[str, dict]): A dictionary of device configurations to remove.
|
||||||
"""
|
"""
|
||||||
row = len(self._device_config)
|
removed = []
|
||||||
self.beginInsertRows(QtCore.QModelIndex(), row, row)
|
for k in device_configs.keys():
|
||||||
self._device_config.append(device)
|
if k not in self._device_config:
|
||||||
self.endInsertRows()
|
logger.warning(f"Device {k} does not exist in the model.")
|
||||||
|
continue
|
||||||
@SafeSlot(int)
|
new_cfg = self._device_config.pop(k)
|
||||||
def remove_device_by_row(self, row: int):
|
new_cfg["name"] = k
|
||||||
"""
|
row = self._list_items.index(new_cfg)
|
||||||
Remove one device row by index. This maps to the row to the source of the data model
|
|
||||||
|
|
||||||
Args:
|
|
||||||
row (int): The index of the device row to remove.
|
|
||||||
"""
|
|
||||||
if 0 <= row < len(self._device_config):
|
|
||||||
self.beginRemoveRows(QtCore.QModelIndex(), row, row)
|
self.beginRemoveRows(QtCore.QModelIndex(), row, row)
|
||||||
self._device_config.pop(row)
|
self._list_items.pop(row)
|
||||||
self.endRemoveRows()
|
self.endRemoveRows()
|
||||||
|
removed.append(k)
|
||||||
|
self.devices_removed.emit(removed)
|
||||||
|
|
||||||
@SafeSlot(list)
|
def clear_table(self):
|
||||||
def remove_devices_by_rows(self, rows: list[int]):
|
|
||||||
"""
|
"""
|
||||||
Remove multiple device rows by their indices.
|
Clear the table.
|
||||||
|
"""
|
||||||
|
device_names = list(self._device_config.keys())
|
||||||
|
self.beginResetModel()
|
||||||
|
self._device_config.clear()
|
||||||
|
self._list_items.clear()
|
||||||
|
self.endResetModel()
|
||||||
|
self.devices_removed.emit(device_names)
|
||||||
|
|
||||||
|
def update_validation_status(self, device_name: str, status: int | ValidationStatus):
|
||||||
|
"""
|
||||||
|
Handle device status changes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
rows (list[int]): The indices of the device rows to remove.
|
device_name (str): The name of the device.
|
||||||
|
status (int): The new status of the device.
|
||||||
"""
|
"""
|
||||||
for row in sorted(rows, reverse=True):
|
if isinstance(status, int):
|
||||||
self.remove_device_by_row(row)
|
status = ValidationStatus(status)
|
||||||
|
if device_name not in self._device_config:
|
||||||
@SafeSlot(str)
|
logger.warning(
|
||||||
def remove_device_by_name(self, name: str):
|
f"Device {device_name} not found in device_config dict {self._device_config}"
|
||||||
"""
|
)
|
||||||
Remove one device row by name.
|
return
|
||||||
|
self._validation_status[device_name] = status
|
||||||
Args:
|
row = None
|
||||||
name (str): The name of the device to remove.
|
for ii, item in enumerate(self._list_items):
|
||||||
"""
|
if item["name"] == device_name:
|
||||||
for row, device in enumerate(self._device_config):
|
row = ii
|
||||||
if device.get("name") == name:
|
|
||||||
self.remove_device_by_row(row)
|
|
||||||
break
|
break
|
||||||
|
if row is None:
|
||||||
|
logger.warning(
|
||||||
|
f"Device {device_name} not found in device_status dict {self._validation_status}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Emit dataChanged for column 0 (status column)
|
||||||
|
index = self.index(row, 0)
|
||||||
|
self.dataChanged.emit(index, index, [QtCore.Qt.DisplayRole])
|
||||||
|
|
||||||
|
|
||||||
class BECTableView(QtWidgets.QTableView):
|
class BECTableView(QtWidgets.QTableView):
|
||||||
@@ -324,12 +425,7 @@ class BECTableView(QtWidgets.QTableView):
|
|||||||
if not proxy_indexes:
|
if not proxy_indexes:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get unique rows (proxy indices) in reverse order so removal indexes stay valid
|
source_rows = self._get_source_rows(proxy_indexes)
|
||||||
proxy_rows = sorted({idx.row() for idx in proxy_indexes}, reverse=True)
|
|
||||||
# Map to source model rows
|
|
||||||
source_rows = [
|
|
||||||
self.model().mapToSource(self.model().index(row, 0)).row() for row in proxy_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
model: DeviceTableModel = self.model().sourceModel() # access underlying model
|
model: DeviceTableModel = self.model().sourceModel() # access underlying model
|
||||||
# Delegate confirmation and removal to helper
|
# Delegate confirmation and removal to helper
|
||||||
@@ -337,14 +433,28 @@ class BECTableView(QtWidgets.QTableView):
|
|||||||
if not removed:
|
if not removed:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _get_source_rows(self, proxy_indexes: list[QtWidgets.QModelIndex]) -> list[int]:
|
||||||
|
"""
|
||||||
|
Map proxy model indices to source model row indices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxy_indexes (list[QModelIndex]): List of proxy model indices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[int]: List of source model row indices.
|
||||||
|
"""
|
||||||
|
proxy_rows = sorted({idx for idx in proxy_indexes}, reverse=True)
|
||||||
|
source_rows = [self.model().mapToSource(idx).row() for idx in proxy_rows]
|
||||||
|
return list(set(source_rows))
|
||||||
|
|
||||||
def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool:
|
def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool:
|
||||||
"""
|
"""
|
||||||
Prompt the user to confirm removal of rows and remove them from the model if accepted.
|
Prompt the user to confirm removal of rows and remove them from the model if accepted.
|
||||||
|
|
||||||
Returns True if rows were removed, False otherwise.
|
Returns True if rows were removed, False otherwise.
|
||||||
"""
|
"""
|
||||||
cfg = model.get_device_config()
|
configs = [model._list_items[r] for r in sorted(source_rows)]
|
||||||
names = [str(cfg[r].get("name", "<unknown>")) for r in sorted(source_rows)]
|
names = [cfg.get("name", "<unknown>") for cfg in configs]
|
||||||
|
|
||||||
msg = QtWidgets.QMessageBox(self)
|
msg = QtWidgets.QMessageBox(self)
|
||||||
msg.setIcon(QtWidgets.QMessageBox.Warning)
|
msg.setIcon(QtWidgets.QMessageBox.Warning)
|
||||||
@@ -359,8 +469,8 @@ class BECTableView(QtWidgets.QTableView):
|
|||||||
|
|
||||||
res = msg.exec_()
|
res = msg.exec_()
|
||||||
if res == QtWidgets.QMessageBox.Ok:
|
if res == QtWidgets.QMessageBox.Ok:
|
||||||
model.remove_devices_by_rows(source_rows)
|
configs_to_be_removed = {model._device_config[name] for name in names}
|
||||||
# TODO add signal for removed devices
|
model.remove_device_configs(configs_to_be_removed)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -372,7 +482,7 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
|
|||||||
self._hidden_rows = set()
|
self._hidden_rows = set()
|
||||||
self._filter_text = ""
|
self._filter_text = ""
|
||||||
self._enable_fuzzy = True
|
self._enable_fuzzy = True
|
||||||
self._filter_columns = [0, 1] # name and deviceClass for search
|
self._filter_columns = [1, 2] # name and deviceClass for search
|
||||||
|
|
||||||
def hide_rows(self, row_indices: list[int]):
|
def hide_rows(self, row_indices: list[int]):
|
||||||
"""
|
"""
|
||||||
@@ -436,9 +546,12 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
|
|||||||
class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||||
"""Device Table View for the device manager."""
|
"""Device Table View for the device manager."""
|
||||||
|
|
||||||
|
selected_device = QtCore.Signal(dict) # Selected device configuration dict[str,dict]
|
||||||
|
device_configs_added = QtCore.Signal(dict) # Dict[str, dict] of configs that were added
|
||||||
|
devices_removed = QtCore.Signal(list) # List of strings with device names that were removed
|
||||||
|
|
||||||
RPC = False
|
RPC = False
|
||||||
PLUGIN = False
|
PLUGIN = False
|
||||||
devices_removed = QtCore.Signal(list)
|
|
||||||
|
|
||||||
def __init__(self, parent=None, client=None):
|
def __init__(self, parent=None, client=None):
|
||||||
super().__init__(client=client, parent=parent, theme_update=True)
|
super().__init__(client=client, parent=parent, theme_update=True)
|
||||||
@@ -455,6 +568,10 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
|||||||
self.layout.addLayout(self.search_controls)
|
self.layout.addLayout(self.search_controls)
|
||||||
self.layout.addWidget(self.table)
|
self.layout.addWidget(self.table)
|
||||||
|
|
||||||
|
# Connect signals
|
||||||
|
self._model.devices_removed.connect(self.devices_removed.emit)
|
||||||
|
self._model.device_configs_added.connect(self.device_configs_added.emit)
|
||||||
|
|
||||||
def _setup_search(self):
|
def _setup_search(self):
|
||||||
"""Create components related to the search functionality"""
|
"""Create components related to the search functionality"""
|
||||||
|
|
||||||
@@ -495,137 +612,199 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
|||||||
"""Setup the table view."""
|
"""Setup the table view."""
|
||||||
# Model + Proxy
|
# Model + Proxy
|
||||||
self.table = BECTableView(self)
|
self.table = BECTableView(self)
|
||||||
self.model = DeviceTableModel(parent=self.table)
|
self._model = DeviceTableModel(parent=self.table)
|
||||||
self.proxy = DeviceFilterProxyModel(parent=self.table)
|
self.proxy = DeviceFilterProxyModel(parent=self.table)
|
||||||
self.proxy.setSourceModel(self.model)
|
self.proxy.setSourceModel(self._model)
|
||||||
self.table.setModel(self.proxy)
|
self.table.setModel(self.proxy)
|
||||||
self.table.setSortingEnabled(True)
|
self.table.setSortingEnabled(True)
|
||||||
|
|
||||||
# Delegates
|
# Delegates
|
||||||
self.checkbox_delegate = CenterCheckBoxDelegate(self.table)
|
colors = get_accent_colors()
|
||||||
|
self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors)
|
||||||
self.wrap_delegate = WrappingTextDelegate(self.table)
|
self.wrap_delegate = WrappingTextDelegate(self.table)
|
||||||
self.tool_tip_delegate = DictToolTipDelegate(self.table)
|
self.tool_tip_delegate = DictToolTipDelegate(self.table)
|
||||||
self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name
|
self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors)
|
||||||
self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass
|
self.table.setItemDelegateForColumn(0, self.validated_delegate) # ValidationStatus
|
||||||
self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority
|
self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name
|
||||||
self.table.setItemDelegateForColumn(3, self.checkbox_delegate) # enabled
|
self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass
|
||||||
self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # readOnly
|
self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority
|
||||||
self.table.setItemDelegateForColumn(5, self.wrap_delegate) # deviceTags
|
self.table.setItemDelegateForColumn(4, self.wrap_delegate) # deviceTags
|
||||||
self.table.setItemDelegateForColumn(6, self.wrap_delegate) # description
|
self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # enabled
|
||||||
|
self.table.setItemDelegateForColumn(6, self.checkbox_delegate) # readOnly
|
||||||
|
|
||||||
# Column resize policies
|
# Column resize policies
|
||||||
# TODO maybe we need here a flexible header options as deviceClass
|
|
||||||
# may get quite long for beamlines plugin repos
|
|
||||||
header = self.table.horizontalHeader()
|
header = self.table.horizontalHeader()
|
||||||
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name
|
header.setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed) # ValidationStatus
|
||||||
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass
|
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # name
|
||||||
header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority
|
header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # deviceClass
|
||||||
header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) # enabled
|
header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority
|
||||||
header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # readOnly
|
header.setSectionResizeMode(4, QtWidgets.QHeaderView.Stretch) # deviceTags
|
||||||
# TODO maybe better stretch...
|
header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) # enabled
|
||||||
header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeToContents) # deviceTags
|
header.setSectionResizeMode(6, QtWidgets.QHeaderView.Fixed) # readOnly
|
||||||
header.setSectionResizeMode(6, QtWidgets.QHeaderView.Stretch) # description
|
|
||||||
self.table.setColumnWidth(3, 82)
|
self.table.setColumnWidth(0, 25)
|
||||||
self.table.setColumnWidth(4, 82)
|
self.table.setColumnWidth(5, 70)
|
||||||
|
self.table.setColumnWidth(6, 70)
|
||||||
|
|
||||||
# Ensure column widths stay fixed
|
# Ensure column widths stay fixed
|
||||||
header.setMinimumSectionSize(70)
|
header.setMinimumSectionSize(25)
|
||||||
header.setDefaultSectionSize(90)
|
header.setDefaultSectionSize(90)
|
||||||
|
|
||||||
# Enable resizing of column
|
# Enable resizing of column
|
||||||
header.sectionResized.connect(self.on_table_resized)
|
self._geometry_resize_proxy = BECSignalProxy(
|
||||||
|
header.geometriesChanged, rateLimit=10, slot=self._on_table_resized
|
||||||
|
)
|
||||||
|
|
||||||
# Selection behavior
|
# Selection behavior
|
||||||
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
|
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
|
||||||
self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
|
self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
|
||||||
|
# Connect to selection model to get selection changes
|
||||||
|
self.table.selectionModel().selectionChanged.connect(self._on_selection_changed)
|
||||||
self.table.horizontalHeader().setHighlightSections(False)
|
self.table.horizontalHeader().setHighlightSections(False)
|
||||||
|
|
||||||
# QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0))
|
# QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0))
|
||||||
|
|
||||||
def device_config(self) -> list[dict]:
|
def get_device_config(self) -> dict[str, dict]:
|
||||||
"""Get the device config."""
|
"""Get the device config."""
|
||||||
return self.model.get_device_config()
|
return self._model.get_device_config()
|
||||||
|
|
||||||
def apply_theme(self, theme: str | None = None):
|
def apply_theme(self, theme: str | None = None):
|
||||||
self.checkbox_delegate.apply_theme(theme)
|
self.checkbox_delegate.apply_theme(theme)
|
||||||
|
self.validated_delegate.apply_theme(theme)
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
########### Slot API #################
|
########### Slot API #################
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
@SafeSlot(int, int, int)
|
@SafeSlot()
|
||||||
def on_table_resized(self, column, old_width, new_width):
|
def _on_table_resized(self, *args):
|
||||||
"""Handle changes to the table column resizing."""
|
"""Handle changes to the table column resizing."""
|
||||||
if column != len(self.model.headers) - 1:
|
option = QtWidgets.QStyleOptionViewItem()
|
||||||
|
model = self.table.model()
|
||||||
|
for row in range(model.rowCount()):
|
||||||
|
index = model.index(row, 4)
|
||||||
|
height = self.wrap_delegate.sizeHint(option, index).height()
|
||||||
|
self.table.setRowHeight(row, height)
|
||||||
|
|
||||||
|
@SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection)
|
||||||
|
def _on_selection_changed(
|
||||||
|
self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Handle selection changes in the device table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
selected (QtCore.QItemSelection): The selected items.
|
||||||
|
deselected (QtCore.QItemSelection): The deselected items.
|
||||||
|
"""
|
||||||
|
# TODO also hook up logic if a config update is propagated from somewhere!
|
||||||
|
# selected_indexes = selected.indexes()
|
||||||
|
selected_indexes = self.table.selectionModel().selectedIndexes()
|
||||||
|
if not selected_indexes:
|
||||||
return
|
return
|
||||||
|
|
||||||
for row in range(self.table.model().rowCount()):
|
source_indexes = [self.proxy.mapToSource(idx) for idx in selected_indexes]
|
||||||
index = self.table.model().index(row, column)
|
source_rows = {idx.row() for idx in source_indexes}
|
||||||
delegate = self.table.itemDelegate(index)
|
configs = [copy.deepcopy(self._model._list_items[r]) for r in sorted(source_rows)]
|
||||||
option = QtWidgets.QStyleOptionViewItem()
|
names = [cfg.pop("name") for cfg in configs]
|
||||||
height = delegate.sizeHint(option, index).height()
|
selected_cfgs = {name: cfg for name, cfg in zip(names, configs)}
|
||||||
self.table.setRowHeight(row, height)
|
self.selected_device.emit(selected_cfgs)
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
##### Ext. Slot API #################
|
##### Ext. Slot API #################
|
||||||
######################################
|
######################################
|
||||||
|
|
||||||
@SafeSlot(list)
|
@SafeSlot(dict)
|
||||||
def set_device_config(self, config: list[dict]):
|
def set_device_config(self, device_configs: dict[str, dict]):
|
||||||
"""
|
"""
|
||||||
Set the device config.
|
Set the device config.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config (list[dict]): The device config to set.
|
config (dict[str,dict]): The device config to set.
|
||||||
"""
|
"""
|
||||||
self.model.set_device_config(config)
|
self._model.set_device_config(device_configs)
|
||||||
|
|
||||||
@SafeSlot()
|
@SafeSlot()
|
||||||
def clear_device_config(self):
|
def clear_device_configs(self):
|
||||||
"""
|
"""Clear the device configs."""
|
||||||
Clear the device config.
|
self._model.clear_table()
|
||||||
"""
|
|
||||||
self.model.set_device_config([])
|
|
||||||
|
|
||||||
@SafeSlot(dict)
|
@SafeSlot(dict)
|
||||||
def add_device(self, device: dict):
|
def add_device_configs(self, device_configs: dict[str, dict]):
|
||||||
"""
|
"""
|
||||||
Add a device to the config.
|
Add devices to the config.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device (dict): The device to add.
|
device_configs (dict[str, dict]): The device configs to add.
|
||||||
"""
|
"""
|
||||||
self.model.add_device(device)
|
self._model.add_device_configs(device_configs)
|
||||||
|
|
||||||
|
@SafeSlot(dict)
|
||||||
|
def remove_device_configs(self, device_configs: dict[str, dict]):
|
||||||
|
"""
|
||||||
|
Remove devices from the config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_configs (dict[str, dict]): The device configs to remove.
|
||||||
|
"""
|
||||||
|
self._model.remove_device_configs(device_configs)
|
||||||
|
|
||||||
@SafeSlot(int)
|
|
||||||
@SafeSlot(str)
|
@SafeSlot(str)
|
||||||
def remove_device(self, dev: int | str):
|
def remove_device(self, device_name: str):
|
||||||
"""
|
"""
|
||||||
Remove the device from the config either by row id, or device name.
|
Remove a device from the config.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dev (int | str): The device to remove, either by row id or device name.
|
device_name (str): The name of the device to remove.
|
||||||
"""
|
"""
|
||||||
if isinstance(dev, int):
|
cfg = self._model._device_config.get(device_name, None)
|
||||||
# TODO test this properly, check with proxy index and source index
|
if cfg is None:
|
||||||
# Use the proxy model to map to the correct row
|
logger.warning(f"Device {device_name} not found in device_config dict")
|
||||||
model_source_index = self.table.model().mapToSource(self.table.model().index(dev, 0))
|
|
||||||
self.model.remove_device_by_row(model_source_index.row())
|
|
||||||
return
|
|
||||||
if isinstance(dev, str):
|
|
||||||
self.model.remove_device_by_name(dev)
|
|
||||||
return
|
return
|
||||||
|
self._model.remove_device_configs({device_name: cfg})
|
||||||
|
|
||||||
|
@SafeSlot(str, int)
|
||||||
|
def update_device_validation(
|
||||||
|
self, device_name: str, validation_status: int | ValidationStatus
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update the validation status of a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name (str): The name of the device.
|
||||||
|
validation_status (int | ValidationStatus): The new validation status.
|
||||||
|
"""
|
||||||
|
self._model.update_validation_status(device_name, validation_status)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
|
widget = QtWidgets.QWidget()
|
||||||
|
layout = QtWidgets.QVBoxLayout(widget)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
window = DeviceTableView()
|
window = DeviceTableView()
|
||||||
|
layout.addWidget(window)
|
||||||
|
# QPushButton
|
||||||
|
button = QtWidgets.QPushButton("Test status_update")
|
||||||
|
layout.addWidget(button)
|
||||||
|
|
||||||
|
def _button_clicked():
|
||||||
|
names = list(window._model._device_config.keys())
|
||||||
|
for name in names:
|
||||||
|
window.update_device_validation(
|
||||||
|
name, ValidationStatus.VALID if np.random.rand() > 0.5 else ValidationStatus.FAILED
|
||||||
|
)
|
||||||
|
|
||||||
|
button.clicked.connect(_button_clicked)
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
config = window.client.device_manager._get_redis_device_config()
|
config = window.client.device_manager._get_redis_device_config()
|
||||||
window.set_device_config(config)
|
names = [cfg.pop("name") for cfg in config]
|
||||||
window.show()
|
config_dict = {name: cfg for name, cfg in zip(names, config)}
|
||||||
|
window.set_device_config(config_dict)
|
||||||
|
widget.show()
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Module with a config view for the device manager."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from bec_lib.logger import bec_logger
|
||||||
|
from qtpy import QtCore, QtWidgets
|
||||||
|
|
||||||
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
|
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
|
||||||
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
|
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||||
|
|
||||||
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
|
||||||
|
class DMConfigView(BECWidget, QtWidgets.QWidget):
|
||||||
|
def __init__(self, parent=None, client=None):
|
||||||
|
super().__init__(client=client, parent=parent, theme_update=True)
|
||||||
|
self.stacked_layout = QtWidgets.QStackedLayout()
|
||||||
|
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.stacked_layout.setSpacing(0)
|
||||||
|
self.setLayout(self.stacked_layout)
|
||||||
|
|
||||||
|
# Monaco widget
|
||||||
|
self.monaco_editor = MonacoWidget()
|
||||||
|
self._customize_monaco()
|
||||||
|
self.stacked_layout.addWidget(self.monaco_editor)
|
||||||
|
|
||||||
|
self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config")
|
||||||
|
self._customize_overlay()
|
||||||
|
self.stacked_layout.addWidget(self._overlay_widget)
|
||||||
|
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||||
|
|
||||||
|
def _customize_monaco(self):
|
||||||
|
|
||||||
|
self.monaco_editor.set_language("yaml")
|
||||||
|
self.monaco_editor.set_vim_mode_enabled(False)
|
||||||
|
self.monaco_editor.set_minimap_enabled(False)
|
||||||
|
# self.monaco_editor.setFixedHeight(600)
|
||||||
|
self.monaco_editor.set_readonly(True)
|
||||||
|
self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False)
|
||||||
|
self.monaco_editor.editor.set_line_numbers_mode("off")
|
||||||
|
|
||||||
|
def _customize_overlay(self):
|
||||||
|
self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self._overlay_widget.setAutoFillBackground(True)
|
||||||
|
self._overlay_widget.setSizePolicy(
|
||||||
|
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
|
||||||
|
)
|
||||||
|
|
||||||
|
@SafeSlot(dict)
|
||||||
|
def on_select_config(self, device: dict):
|
||||||
|
"""Handle selection of a device from the device table."""
|
||||||
|
if len(device) != 1:
|
||||||
|
text = ""
|
||||||
|
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
text = yaml.dump(device, default_flow_style=False)
|
||||||
|
self.stacked_layout.setCurrentWidget(self.monaco_editor)
|
||||||
|
except Exception:
|
||||||
|
content = traceback.format_exc()
|
||||||
|
logger.error(f"Error converting device to YAML:\n{content}")
|
||||||
|
text = ""
|
||||||
|
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||||
|
self.monaco_editor.set_readonly(False) # Enable editing
|
||||||
|
text = text.rstrip()
|
||||||
|
self.monaco_editor.set_text(text)
|
||||||
|
self.monaco_editor.set_readonly(True) # Disable editing again
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
config_view = DMConfigView()
|
||||||
|
config_view.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Module to visualize the docstring of a device class."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from bec_lib.logger import bec_logger
|
||||||
|
from bec_lib.plugin_helper import get_plugin_class, plugin_package_name
|
||||||
|
from bec_lib.utils.rpc_utils import rgetattr
|
||||||
|
from qtpy import QtCore, QtWidgets
|
||||||
|
|
||||||
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
|
|
||||||
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ophyd
|
||||||
|
import ophyd_devices
|
||||||
|
|
||||||
|
READY_TO_VIEW = True
|
||||||
|
except ImportError:
|
||||||
|
logger.warning(f"Optional dependencies not available: {ImportError}")
|
||||||
|
ophyd_devices = None
|
||||||
|
ophyd = None
|
||||||
|
|
||||||
|
|
||||||
|
class DocstringView(QtWidgets.QTextEdit):
|
||||||
|
def __init__(self, parent: QtWidgets.QWidget | None = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setReadOnly(True)
|
||||||
|
self.setFocusPolicy(QtCore.Qt.NoFocus)
|
||||||
|
if not READY_TO_VIEW:
|
||||||
|
self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.")
|
||||||
|
self.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _format_docstring(self, doc: str | None) -> str:
|
||||||
|
if not doc:
|
||||||
|
return "<i>No docstring available.</i>"
|
||||||
|
|
||||||
|
# Escape HTML
|
||||||
|
doc = doc.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
# Remove leading/trailing blank lines from the entire docstring
|
||||||
|
lines = [line.rstrip() for line in doc.splitlines()]
|
||||||
|
while lines and lines[0].strip() == "":
|
||||||
|
lines.pop(0)
|
||||||
|
while lines and lines[-1].strip() == "":
|
||||||
|
lines.pop()
|
||||||
|
doc = "\n".join(lines)
|
||||||
|
|
||||||
|
# Improved regex: match section header + all following indented lines
|
||||||
|
section_regex = re.compile(
|
||||||
|
r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b(?:\n([ \t]+.*))*",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def strip_section(match: re.Match) -> str:
|
||||||
|
# Capture all lines in the match
|
||||||
|
block = match.group(0)
|
||||||
|
lines = block.splitlines()
|
||||||
|
# Remove leading/trailing empty lines within the section
|
||||||
|
lines = [line for line in lines if line.strip() != ""]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
doc = section_regex.sub(strip_section, doc)
|
||||||
|
|
||||||
|
# Highlight section titles
|
||||||
|
doc = re.sub(
|
||||||
|
r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b", r"<b>\1</b>", doc
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert indented blocks to <pre> and strip leading/trailing newlines
|
||||||
|
def pre_block(match: re.Match) -> str:
|
||||||
|
text = match.group(0).strip("\n")
|
||||||
|
return f"<pre>{text}</pre>"
|
||||||
|
|
||||||
|
doc = re.sub(r"(?m)(?:\n[ \t]+.*)+", pre_block, doc)
|
||||||
|
|
||||||
|
# Replace remaining newlines with <br> and collapse multiple <br>
|
||||||
|
doc = doc.replace("\n", "<br>")
|
||||||
|
doc = re.sub(r"(<br>)+", r"<br>", doc)
|
||||||
|
doc = doc.strip("<br>")
|
||||||
|
|
||||||
|
return f"<div style='font-family: sans-serif; font-size: 12pt;'>{doc}</div>"
|
||||||
|
|
||||||
|
def _set_text(self, text: str):
|
||||||
|
self.setReadOnly(False)
|
||||||
|
self.setMarkdown(text)
|
||||||
|
# self.setHtml(self._format_docstring(text))
|
||||||
|
self.setReadOnly(True)
|
||||||
|
|
||||||
|
@SafeSlot(dict)
|
||||||
|
def on_select_config(self, device: dict):
|
||||||
|
if len(device) != 1:
|
||||||
|
self._set_text("")
|
||||||
|
return
|
||||||
|
k = next(iter(device))
|
||||||
|
device_class = device[k].get("deviceClass", "")
|
||||||
|
self.set_device_class(device_class)
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def set_device_class(self, device_class_str: str) -> None:
|
||||||
|
docstring = ""
|
||||||
|
if not READY_TO_VIEW:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd])
|
||||||
|
docstring = inspect.getdoc(module_cls)
|
||||||
|
self._set_text(docstring or "No docstring available.")
|
||||||
|
except Exception:
|
||||||
|
content = traceback.format_exc()
|
||||||
|
logger.error(f"Error retrieving docstring for {device_class_str}: {content}")
|
||||||
|
self._set_text(f"Error retrieving docstring for {device_class_str}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
config_view = DocstringView()
|
||||||
|
config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera")
|
||||||
|
config_view.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"""Module to run a static tests for devices from a yaml config."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
from html import escape
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import bec_lib
|
||||||
|
from bec_lib.logger import bec_logger
|
||||||
|
from bec_qthemes import material_icon
|
||||||
|
from ophyd import status
|
||||||
|
from qtpy import QtCore, QtGui, QtWidgets
|
||||||
|
|
||||||
|
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.widgets.editors.web_console.web_console import WebConsole
|
||||||
|
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||||
|
|
||||||
|
READY_TO_TEST = False
|
||||||
|
|
||||||
|
logger = bec_logger.logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
import bec_server
|
||||||
|
import ophyd_devices
|
||||||
|
|
||||||
|
READY_TO_TEST = True
|
||||||
|
except ImportError:
|
||||||
|
logger.warning(f"Optional dependencies not available: {ImportError}")
|
||||||
|
ophyd_devices = None
|
||||||
|
bec_server = None
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma no cover
|
||||||
|
try:
|
||||||
|
from ophyd_devices.utils.static_device_test import StaticDeviceTest
|
||||||
|
except ImportError:
|
||||||
|
StaticDeviceTest = None
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationStatus(int, enum.Enum):
|
||||||
|
"""Validation status for device configurations."""
|
||||||
|
|
||||||
|
PENDING = 0 # colors.default
|
||||||
|
VALID = 1 # colors.highlight
|
||||||
|
FAILED = 2 # colors.emergency
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceValidationResult(QtCore.QObject):
|
||||||
|
"""Simple object to inject validation signals into QRunnable."""
|
||||||
|
|
||||||
|
# Device validation signal, device_name, ValidationStatus as int, error message or ''
|
||||||
|
device_validated = QtCore.Signal(str, bool, str)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceValidationRunnable(QtCore.QRunnable):
|
||||||
|
"""Runnable for validating a device configuration."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_name: str,
|
||||||
|
config: dict,
|
||||||
|
static_device_test: StaticDeviceTest | None,
|
||||||
|
connect: bool = False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the device validation runnable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name (str): The name of the device to validate.
|
||||||
|
config (dict): The configuration dictionary for the device.
|
||||||
|
static_device_test (StaticDeviceTest): The static device test instance.
|
||||||
|
connect (bool, optional): Whether to connect to the device. Defaults to False.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.device_name = device_name
|
||||||
|
self.config = config
|
||||||
|
self._connect = connect
|
||||||
|
self._static_device_test = static_device_test
|
||||||
|
self.signals = DeviceValidationResult()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Run method for device validation."""
|
||||||
|
if self._static_device_test is None:
|
||||||
|
logger.error(
|
||||||
|
f"Ophyd devices or bec_server not available, cannot run validation for device {self.device_name}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._static_device_test.config = {self.device_name: self.config}
|
||||||
|
results = self._static_device_test.run_with_list_output(connect=self._connect)
|
||||||
|
success = results[0].success
|
||||||
|
msg = results[0].message
|
||||||
|
self.signals.device_validated.emit(self.device_name, success, msg)
|
||||||
|
except Exception:
|
||||||
|
content = traceback.format_exc()
|
||||||
|
logger.error(f"Validation failed for device {self.device_name}. Exception: {content}")
|
||||||
|
self.signals.device_validated.emit(self.device_name, False, content)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationListItem(QtWidgets.QWidget):
|
||||||
|
"""Custom list item widget showing device name and validation status."""
|
||||||
|
|
||||||
|
def __init__(self, device_name: str, device_config: dict, parent=None):
|
||||||
|
"""
|
||||||
|
Initialize the validation list item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name (str): The name of the device.
|
||||||
|
device_config (dict): The configuration of the device.
|
||||||
|
validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status.
|
||||||
|
parent (QtWidgets.QWidget, optional): The parent widget.
|
||||||
|
"""
|
||||||
|
super().__init__(parent)
|
||||||
|
self.main_layout = QtWidgets.QHBoxLayout(self)
|
||||||
|
self.main_layout.setContentsMargins(2, 2, 2, 2)
|
||||||
|
self.main_layout.setSpacing(4)
|
||||||
|
self.device_name = device_name
|
||||||
|
self.device_config = device_config
|
||||||
|
self.validation_msg = "Validation in progress..."
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Setup the UI for the list item."""
|
||||||
|
label = QtWidgets.QLabel(self.device_name)
|
||||||
|
self.main_layout.addWidget(label)
|
||||||
|
self.main_layout.addStretch()
|
||||||
|
self._spinner = SpinnerWidget(parent=self)
|
||||||
|
self._spinner.speed = 80
|
||||||
|
self._spinner.setFixedSize(24, 24)
|
||||||
|
self.main_layout.addWidget(self._spinner)
|
||||||
|
self._base_style = "font-weight: bold;"
|
||||||
|
self.setStyleSheet(self._base_style)
|
||||||
|
self._start_spinner()
|
||||||
|
|
||||||
|
def _start_spinner(self):
|
||||||
|
"""Start the spinner animation."""
|
||||||
|
self._spinner.start()
|
||||||
|
QtWidgets.QApplication.processEvents()
|
||||||
|
|
||||||
|
def _stop_spinner(self):
|
||||||
|
"""Stop the spinner animation."""
|
||||||
|
self._spinner.stop()
|
||||||
|
self._spinner.setVisible(False)
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def on_validation_restart(self):
|
||||||
|
"""Handle validation restart."""
|
||||||
|
self.validation_msg = ""
|
||||||
|
self._start_spinner()
|
||||||
|
self.setStyleSheet("") # Check if this works as expected
|
||||||
|
|
||||||
|
@SafeSlot(str)
|
||||||
|
def on_validation_failed(self, error_msg: str):
|
||||||
|
"""Handle validation failure."""
|
||||||
|
self.validation_msg = error_msg
|
||||||
|
colors = get_accent_colors()
|
||||||
|
self._stop_spinner()
|
||||||
|
self.main_layout.removeWidget(self._spinner)
|
||||||
|
self._spinner.deleteLater()
|
||||||
|
label = QtWidgets.QLabel("")
|
||||||
|
icon = material_icon("error", color=colors.emergency, size=(24, 24))
|
||||||
|
label.setPixmap(icon)
|
||||||
|
self.main_layout.addWidget(label)
|
||||||
|
|
||||||
|
|
||||||
|
class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||||
|
"""Widget to test device configurations using ophyd devices."""
|
||||||
|
|
||||||
|
# Signal to emit the validation status of a device
|
||||||
|
device_validated = QtCore.Signal(str, int)
|
||||||
|
|
||||||
|
def __init__(self, parent=None, client=None):
|
||||||
|
super().__init__(parent=parent, client=client)
|
||||||
|
if not READY_TO_TEST:
|
||||||
|
self.setDisabled(True)
|
||||||
|
self.static_device_test = None
|
||||||
|
else:
|
||||||
|
from ophyd_devices.utils.static_device_test import StaticDeviceTest
|
||||||
|
|
||||||
|
self.static_device_test = StaticDeviceTest(config_dict={})
|
||||||
|
self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {}
|
||||||
|
self._thread_pool = QtCore.QThreadPool.globalInstance()
|
||||||
|
|
||||||
|
self._main_layout = QtWidgets.QVBoxLayout(self)
|
||||||
|
self._main_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._main_layout.setSpacing(4)
|
||||||
|
|
||||||
|
# We add a splitter between the list and the text box
|
||||||
|
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
|
||||||
|
self._main_layout.addWidget(self.splitter)
|
||||||
|
|
||||||
|
self._setup_list_ui()
|
||||||
|
self._setup_textbox_ui()
|
||||||
|
|
||||||
|
def _setup_list_ui(self):
|
||||||
|
"""Setup the list UI."""
|
||||||
|
self._list_widget = QtWidgets.QListWidget(self)
|
||||||
|
self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
|
||||||
|
self.splitter.addWidget(self._list_widget)
|
||||||
|
# Connect signals
|
||||||
|
self._list_widget.currentItemChanged.connect(self._on_current_item_changed)
|
||||||
|
|
||||||
|
def _setup_textbox_ui(self):
|
||||||
|
"""Setup the text box UI."""
|
||||||
|
self._text_box = QtWidgets.QTextEdit(self)
|
||||||
|
self._text_box.setReadOnly(True)
|
||||||
|
self._text_box.setFocusPolicy(QtCore.Qt.NoFocus)
|
||||||
|
self.splitter.addWidget(self._text_box)
|
||||||
|
|
||||||
|
@SafeSlot(dict)
|
||||||
|
def add_device_configs(self, device_configs: dict[str, dict]) -> None:
|
||||||
|
"""Receive an update with device configs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_configs (dict[str, dict]): The updated device configurations.
|
||||||
|
"""
|
||||||
|
for device_name, device_config in device_configs.items():
|
||||||
|
if device_name in self._device_list_items:
|
||||||
|
logger.error(f"Device {device_name} is already in the list.")
|
||||||
|
return
|
||||||
|
item = QtWidgets.QListWidgetItem(self._list_widget)
|
||||||
|
widget = ValidationListItem(device_name=device_name, device_config=device_config)
|
||||||
|
|
||||||
|
# wrap it in a QListWidgetItem
|
||||||
|
item.setSizeHint(widget.sizeHint())
|
||||||
|
self._list_widget.addItem(item)
|
||||||
|
self._list_widget.setItemWidget(item, widget)
|
||||||
|
self._device_list_items[device_name] = item
|
||||||
|
self._run_device_validation(widget)
|
||||||
|
|
||||||
|
@SafeSlot(dict)
|
||||||
|
def remove_device_configs(self, device_configs: dict[str, dict]) -> None:
|
||||||
|
"""Remove device configs from the list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name (str): The name of the device to remove.
|
||||||
|
"""
|
||||||
|
for device_name in device_configs.keys():
|
||||||
|
if device_name not in self._device_list_items:
|
||||||
|
logger.warning(f"Device {device_name} not found in list.")
|
||||||
|
return
|
||||||
|
self._remove_list_item(device_name)
|
||||||
|
|
||||||
|
def _remove_list_item(self, device_name: str):
|
||||||
|
"""Remove a device from the list."""
|
||||||
|
# Get the list item
|
||||||
|
item = self._device_list_items.pop(device_name)
|
||||||
|
|
||||||
|
# Retrieve the custom widget attached to the item
|
||||||
|
widget = self._list_widget.itemWidget(item)
|
||||||
|
if widget is not None:
|
||||||
|
widget.deleteLater() # clean up custom widget
|
||||||
|
|
||||||
|
# Remove the item from the QListWidget
|
||||||
|
row = self._list_widget.row(item)
|
||||||
|
self._list_widget.takeItem(row)
|
||||||
|
|
||||||
|
def _run_device_validation(self, widget: ValidationListItem):
|
||||||
|
"""
|
||||||
|
Run the device validation in a separate thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
widget (ValidationListItem): The widget to validate.
|
||||||
|
"""
|
||||||
|
if not READY_TO_TEST:
|
||||||
|
logger.error("Ophyd devices or bec_server not available, cannot run validation.")
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
widget.device_name in self.client.device_manager.devices
|
||||||
|
): # TODO and config has to be exact the same..
|
||||||
|
self._on_device_validated(
|
||||||
|
widget.device_name,
|
||||||
|
ValidationStatus.VALID,
|
||||||
|
f"Device {widget.device_name} is already in active config",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
runnable = DeviceValidationRunnable(
|
||||||
|
device_name=widget.device_name,
|
||||||
|
config=widget.device_config,
|
||||||
|
static_device_test=self.static_device_test,
|
||||||
|
connect=False,
|
||||||
|
)
|
||||||
|
runnable.signals.device_validated.connect(self._on_device_validated)
|
||||||
|
self._thread_pool.start(runnable)
|
||||||
|
|
||||||
|
@SafeSlot(str, bool, str)
|
||||||
|
def _on_device_validated(self, device_name: str, success: bool, message: str):
|
||||||
|
"""Handle the device validation result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name (str): The name of the device.
|
||||||
|
success (bool): Whether the validation was successful.
|
||||||
|
message (str): The validation message.
|
||||||
|
"""
|
||||||
|
logger.info(f"Device {device_name} validation result: {success}, message: {message}")
|
||||||
|
item = self._device_list_items.get(device_name, None)
|
||||||
|
if not item:
|
||||||
|
logger.error(f"Device {device_name} not found in the list.")
|
||||||
|
return
|
||||||
|
if success:
|
||||||
|
self._remove_list_item(device_name=device_name)
|
||||||
|
self.device_validated.emit(device_name, ValidationStatus.VALID.value)
|
||||||
|
else:
|
||||||
|
widget: ValidationListItem = self._list_widget.itemWidget(item)
|
||||||
|
widget.on_validation_failed(message)
|
||||||
|
self.device_validated.emit(device_name, ValidationStatus.FAILED.value)
|
||||||
|
|
||||||
|
def _on_current_item_changed(
|
||||||
|
self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem
|
||||||
|
):
|
||||||
|
"""Handle the current item change in the list widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current (QListWidgetItem): The currently selected item.
|
||||||
|
previous (QListWidgetItem): The previously selected item.
|
||||||
|
"""
|
||||||
|
widget: ValidationListItem = self._list_widget.itemWidget(current)
|
||||||
|
if widget:
|
||||||
|
try:
|
||||||
|
formatted_html = self._format_validation_message(widget.validation_msg)
|
||||||
|
self._text_box.setHtml(formatted_html)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error formatting validation message: {e}")
|
||||||
|
self._text_box.setPlainText(widget.validation_msg)
|
||||||
|
|
||||||
|
def _format_validation_message(self, raw_msg: str) -> str:
|
||||||
|
"""Simple HTML formatting for validation messages, wrapping text naturally."""
|
||||||
|
if not raw_msg.strip():
|
||||||
|
return "<i>Validation in progress...</i>"
|
||||||
|
if raw_msg == "Validation in progress...":
|
||||||
|
return "<i>Validation in progress...</i>"
|
||||||
|
|
||||||
|
raw_msg = escape(raw_msg)
|
||||||
|
|
||||||
|
# Split into lines
|
||||||
|
lines = raw_msg.splitlines()
|
||||||
|
summary = lines[0] if lines else "Validation Result"
|
||||||
|
rest = "\n".join(lines[1:]).strip()
|
||||||
|
|
||||||
|
# Split traceback / final ERROR
|
||||||
|
tb_match = re.search(r"(Traceback.*|ERROR:.*)$", rest, re.DOTALL | re.MULTILINE)
|
||||||
|
if tb_match:
|
||||||
|
main_text = rest[: tb_match.start()].strip()
|
||||||
|
error_detail = tb_match.group().strip()
|
||||||
|
else:
|
||||||
|
main_text = rest
|
||||||
|
error_detail = ""
|
||||||
|
|
||||||
|
# Highlight field names in orange (simple regex for word: Field)
|
||||||
|
main_text_html = re.sub(
|
||||||
|
r"(\b\w+\b)(?=: Field required)",
|
||||||
|
r'<span style="color:#FF8C00; font-weight:bold;">\1</span>',
|
||||||
|
main_text,
|
||||||
|
)
|
||||||
|
# Wrap in div for monospace, allowing wrapping
|
||||||
|
main_text_html = (
|
||||||
|
f'<div style="white-space: pre-wrap;">{main_text_html}</div>' if main_text_html else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Traceback / error in red
|
||||||
|
error_html = (
|
||||||
|
f'<div style="white-space: pre-wrap; color:#A00000;">{error_detail}</div>'
|
||||||
|
if error_detail
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Summary at top, dark red
|
||||||
|
html = (
|
||||||
|
f'<div style="font-family: monospace; font-size:13px; white-space: pre-wrap;">'
|
||||||
|
f'<div style="font-weight:bold; color:#8B0000; margin-bottom:4px;">{summary}</div>'
|
||||||
|
f"{main_text_html}"
|
||||||
|
f"{error_html}"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
return html
|
||||||
|
|
||||||
|
@SafeSlot()
|
||||||
|
def clear_list(self):
|
||||||
|
"""Clear the device list."""
|
||||||
|
self._thread_pool.clear()
|
||||||
|
if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish
|
||||||
|
logger.error("Failed to wait for threads to finish. Removing items from the list.")
|
||||||
|
self._device_list_items.clear()
|
||||||
|
self._list_widget.clear()
|
||||||
|
|
||||||
|
def remove_device(self, device_name: str):
|
||||||
|
"""Remove a device from the list."""
|
||||||
|
item = self._device_list_items.pop(device_name, None)
|
||||||
|
if item:
|
||||||
|
self._list_widget.removeItemWidget(item)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from bec_lib.bec_yaml_loader import yaml_load
|
||||||
|
|
||||||
|
# pylint: disable=ungrouped-imports
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
device_manager_ophyd_test = DMOphydTest()
|
||||||
|
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml"
|
||||||
|
cfg = yaml_load(config_path)
|
||||||
|
cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
|
||||||
|
device_manager_ophyd_test.add_device_configs(cfg)
|
||||||
|
device_manager_ophyd_test.show()
|
||||||
|
device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test")
|
||||||
|
device_manager_ophyd_test.resize(800, 600)
|
||||||
|
sys.exit(app.exec_())
|
||||||
@@ -20,7 +20,7 @@ from qtpy.QtWidgets import (
|
|||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
from bec_widgets.utils import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import get_accent_colors
|
from bec_widgets.utils.colors import apply_theme, get_accent_colors
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||||
@@ -45,7 +45,7 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
Widget to submit new scans to the queue.
|
Widget to submit new scans to the queue.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
USER_ACCESS = ["remove", "screenshot"]
|
USER_ACCESS = ["attach", "detach", "screenshot"]
|
||||||
PLUGIN = True
|
PLUGIN = True
|
||||||
ICON_NAME = "tune"
|
ICON_NAME = "tune"
|
||||||
ARG_BOX_POSITION: int = 2
|
ARG_BOX_POSITION: int = 2
|
||||||
@@ -136,13 +136,8 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
self.scan_control_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
self.scan_control_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||||
self.button_layout = QHBoxLayout(self.scan_control_group)
|
self.button_layout = QHBoxLayout(self.scan_control_group)
|
||||||
self.button_run_scan = QPushButton("Start", self.scan_control_group)
|
self.button_run_scan = QPushButton("Start", self.scan_control_group)
|
||||||
self.button_run_scan.setStyleSheet(
|
self.button_run_scan.setProperty("variant", "success")
|
||||||
f"background-color: {palette.success.name()}; color: white"
|
|
||||||
)
|
|
||||||
self.button_stop_scan = StopButton(parent=self.scan_control_group)
|
self.button_stop_scan = StopButton(parent=self.scan_control_group)
|
||||||
self.button_stop_scan.setStyleSheet(
|
|
||||||
f"background-color: {palette.emergency.name()}; color: white"
|
|
||||||
)
|
|
||||||
self.button_layout.addWidget(self.button_run_scan)
|
self.button_layout.addWidget(self.button_run_scan)
|
||||||
self.button_layout.addWidget(self.button_stop_scan)
|
self.button_layout.addWidget(self.button_stop_scan)
|
||||||
self.layout.addWidget(self.scan_control_group)
|
self.layout.addWidget(self.scan_control_group)
|
||||||
@@ -547,12 +542,10 @@ class ScanControl(BECWidget, QWidget):
|
|||||||
|
|
||||||
# Application example
|
# Application example
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
from bec_widgets.utils.colors import set_theme
|
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
scan_control = ScanControl()
|
scan_control = ScanControl()
|
||||||
|
|
||||||
set_theme("auto")
|
apply_theme("dark")
|
||||||
window = scan_control
|
window = scan_control
|
||||||
window.show()
|
window.show()
|
||||||
app.exec()
|
app.exec()
|
||||||
|
|||||||
@@ -175,10 +175,10 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
# pylint: disable=import-outside-toplevel
|
# pylint: disable=import-outside-toplevel
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
widget.setFixedSize(200, 200)
|
widget.setFixedSize(200, 200)
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|||||||
@@ -249,10 +249,10 @@ class DictBackedTable(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
|
|
||||||
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
|
||||||
window.show()
|
window.show()
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class MonacoWidget(BECWidget, QWidget):
|
|||||||
"set_vim_mode_enabled",
|
"set_vim_mode_enabled",
|
||||||
"set_lsp_header",
|
"set_lsp_header",
|
||||||
"get_lsp_header",
|
"get_lsp_header",
|
||||||
|
"attach",
|
||||||
|
"detach",
|
||||||
|
"screenshot",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
|
|
||||||
from bec_lib.metadata_schema import BasicScanMetadata
|
from bec_lib.metadata_schema import BasicScanMetadata
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
class ExampleSchema1(BasicScanMetadata):
|
class ExampleSchema1(BasicScanMetadata):
|
||||||
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
|
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
|
||||||
@@ -141,7 +141,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
layout.addWidget(selection)
|
layout.addWidget(selection)
|
||||||
layout.addWidget(scan_metadata)
|
layout.addWidget(scan_metadata)
|
||||||
|
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
window = w
|
window = w
|
||||||
window.show()
|
window.show()
|
||||||
app.exec()
|
app.exec()
|
||||||
|
|||||||
@@ -21,7 +21,16 @@ class WebsiteWidget(BECWidget, QWidget):
|
|||||||
|
|
||||||
PLUGIN = True
|
PLUGIN = True
|
||||||
ICON_NAME = "travel_explore"
|
ICON_NAME = "travel_explore"
|
||||||
USER_ACCESS = ["set_url", "get_url", "reload", "back", "forward"]
|
USER_ACCESS = [
|
||||||
|
"set_url",
|
||||||
|
"get_url",
|
||||||
|
"reload",
|
||||||
|
"back",
|
||||||
|
"forward",
|
||||||
|
"attach",
|
||||||
|
"detach",
|
||||||
|
"screenshot",
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
|
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
|
||||||
|
|||||||
@@ -407,10 +407,10 @@ class Minesweeper(BECWidget, QWidget):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
set_theme("light")
|
apply_theme("light")
|
||||||
widget = Minesweeper()
|
widget = Minesweeper()
|
||||||
widget.show()
|
widget.show()
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ class Heatmap(ImageBase):
|
|||||||
"auto_range_y.setter",
|
"auto_range_y.setter",
|
||||||
"minimal_crosshair_precision",
|
"minimal_crosshair_precision",
|
||||||
"minimal_crosshair_precision.setter",
|
"minimal_crosshair_precision.setter",
|
||||||
|
"attach",
|
||||||
|
"detach",
|
||||||
"screenshot",
|
"screenshot",
|
||||||
# ImageView Specific Settings
|
# ImageView Specific Settings
|
||||||
"color_map",
|
"color_map",
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ class Image(ImageBase):
|
|||||||
"auto_range_y.setter",
|
"auto_range_y.setter",
|
||||||
"minimal_crosshair_precision",
|
"minimal_crosshair_precision",
|
||||||
"minimal_crosshair_precision.setter",
|
"minimal_crosshair_precision.setter",
|
||||||
|
"attach",
|
||||||
|
"detach",
|
||||||
"screenshot",
|
"screenshot",
|
||||||
# ImageView Specific Settings
|
# ImageView Specific Settings
|
||||||
"color_map",
|
"color_map",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from qtpy.QtGui import QColor
|
|||||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors, ConnectionConfig
|
from bec_widgets.utils import Colors, ConnectionConfig
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||||
@@ -128,6 +128,8 @@ class MotorMap(PlotBase):
|
|||||||
"y_log.setter",
|
"y_log.setter",
|
||||||
"legend_label_size",
|
"legend_label_size",
|
||||||
"legend_label_size.setter",
|
"legend_label_size.setter",
|
||||||
|
"attach",
|
||||||
|
"detach",
|
||||||
"screenshot",
|
"screenshot",
|
||||||
# motor_map specific
|
# motor_map specific
|
||||||
"color",
|
"color",
|
||||||
@@ -828,7 +830,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = DemoApp()
|
widget = DemoApp()
|
||||||
widget.show()
|
widget.show()
|
||||||
widget.resize(1400, 600)
|
widget.resize(1400, 600)
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ class MultiWaveform(PlotBase):
|
|||||||
"legend_label_size.setter",
|
"legend_label_size.setter",
|
||||||
"minimal_crosshair_precision",
|
"minimal_crosshair_precision",
|
||||||
"minimal_crosshair_precision.setter",
|
"minimal_crosshair_precision.setter",
|
||||||
|
"attach",
|
||||||
|
"detach",
|
||||||
"screenshot",
|
"screenshot",
|
||||||
# MultiWaveform Specific RPC Access
|
# MultiWaveform Specific RPC Access
|
||||||
"highlighted_index",
|
"highlighted_index",
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class PlotBase(BECWidget, QWidget):
|
|||||||
self._init_ui()
|
self._init_ui()
|
||||||
|
|
||||||
self._connect_to_theme_change()
|
self._connect_to_theme_change()
|
||||||
self._update_theme()
|
self._update_theme(None)
|
||||||
|
|
||||||
def apply_theme(self, theme: str):
|
def apply_theme(self, theme: str):
|
||||||
self.round_plot_widget.apply_theme(theme)
|
self.round_plot_widget.apply_theme(theme)
|
||||||
@@ -142,6 +142,8 @@ class PlotBase(BECWidget, QWidget):
|
|||||||
def _init_ui(self):
|
def _init_ui(self):
|
||||||
self.layout.addWidget(self.layout_manager)
|
self.layout.addWidget(self.layout_manager)
|
||||||
self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget)
|
self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget)
|
||||||
|
self.round_plot_widget.setProperty("variant", "plot_background")
|
||||||
|
self.round_plot_widget.setProperty("frameless", True)
|
||||||
|
|
||||||
self.layout_manager.add_widget(self.round_plot_widget)
|
self.layout_manager.add_widget(self.round_plot_widget)
|
||||||
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
|
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from qtpy.QtCore import QTimer, Signal
|
|||||||
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils import Colors, ConnectionConfig
|
from bec_widgets.utils import Colors, ConnectionConfig
|
||||||
from bec_widgets.utils.colors import set_theme
|
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
|
||||||
@@ -84,6 +83,8 @@ class ScatterWaveform(PlotBase):
|
|||||||
"legend_label_size.setter",
|
"legend_label_size.setter",
|
||||||
"minimal_crosshair_precision",
|
"minimal_crosshair_precision",
|
||||||
"minimal_crosshair_precision.setter",
|
"minimal_crosshair_precision.setter",
|
||||||
|
"attach",
|
||||||
|
"detach",
|
||||||
"screenshot",
|
"screenshot",
|
||||||
# Scatter Waveform Specific RPC Access
|
# Scatter Waveform Specific RPC Access
|
||||||
"main_curve",
|
"main_curve",
|
||||||
@@ -544,8 +545,10 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = DemoApp()
|
widget = DemoApp()
|
||||||
widget.show()
|
widget.show()
|
||||||
widget.resize(1400, 600)
|
widget.resize(1400, 600)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from bec_lib.logger import bec_logger
|
|||||||
from bec_qthemes._icon.material_icons import material_icon
|
from bec_qthemes._icon.material_icons import material_icon
|
||||||
from qtpy.QtCore import Qt
|
from qtpy.QtCore import Qt
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
@@ -70,6 +71,7 @@ class CurveRow(QTreeWidgetItem):
|
|||||||
# A top-level device row.
|
# A top-level device row.
|
||||||
super().__init__(tree)
|
super().__init__(tree)
|
||||||
|
|
||||||
|
self.app = QApplication.instance()
|
||||||
self.tree = tree
|
self.tree = tree
|
||||||
self.parent_item = parent_item
|
self.parent_item = parent_item
|
||||||
self.curve_tree = tree.parent() # The CurveTree widget
|
self.curve_tree = tree.parent() # The CurveTree widget
|
||||||
@@ -115,7 +117,16 @@ class CurveRow(QTreeWidgetItem):
|
|||||||
|
|
||||||
# If device row, add "Add DAP" button
|
# If device row, add "Add DAP" button
|
||||||
if self.source == "device":
|
if self.source == "device":
|
||||||
self.add_dap_button = QPushButton("DAP")
|
self.add_dap_button = QToolButton()
|
||||||
|
analysis_icon = material_icon(
|
||||||
|
"monitoring",
|
||||||
|
size=(20, 20),
|
||||||
|
convert_to_pixmap=False,
|
||||||
|
filled=False,
|
||||||
|
color=self.app.theme.colors["FG"].toTuple(),
|
||||||
|
)
|
||||||
|
self.add_dap_button.setIcon(analysis_icon)
|
||||||
|
self.add_dap_button.setToolTip("Add DAP")
|
||||||
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
|
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
|
||||||
actions_layout.addWidget(self.add_dap_button)
|
actions_layout.addWidget(self.add_dap_button)
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from qtpy.QtWidgets import (
|
|||||||
|
|
||||||
from bec_widgets.utils import ConnectionConfig
|
from bec_widgets.utils import ConnectionConfig
|
||||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||||
from bec_widgets.utils.colors import Colors, set_theme
|
from bec_widgets.utils.colors import Colors, apply_theme
|
||||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||||
@@ -63,6 +63,10 @@ class Waveform(PlotBase):
|
|||||||
RPC = True
|
RPC = True
|
||||||
ICON_NAME = "show_chart"
|
ICON_NAME = "show_chart"
|
||||||
USER_ACCESS = [
|
USER_ACCESS = [
|
||||||
|
# BECWidget Base Class
|
||||||
|
"attach",
|
||||||
|
"detach",
|
||||||
|
"screenshot",
|
||||||
# General PlotBase Settings
|
# General PlotBase Settings
|
||||||
"_config_dict",
|
"_config_dict",
|
||||||
"enable_toolbar",
|
"enable_toolbar",
|
||||||
@@ -105,7 +109,6 @@ class Waveform(PlotBase):
|
|||||||
"legend_label_size.setter",
|
"legend_label_size.setter",
|
||||||
"minimal_crosshair_precision",
|
"minimal_crosshair_precision",
|
||||||
"minimal_crosshair_precision.setter",
|
"minimal_crosshair_precision.setter",
|
||||||
"screenshot",
|
|
||||||
# Waveform Specific RPC Access
|
# Waveform Specific RPC Access
|
||||||
"curves",
|
"curves",
|
||||||
"x_mode",
|
"x_mode",
|
||||||
@@ -2056,7 +2059,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = DemoApp()
|
widget = DemoApp()
|
||||||
widget.show()
|
widget.show()
|
||||||
widget.resize(1400, 600)
|
widget.resize(1400, 600)
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ class RingProgressBar(BECWidget, QWidget):
|
|||||||
"set_diameter",
|
"set_diameter",
|
||||||
"reset_diameter",
|
"reset_diameter",
|
||||||
"enable_auto_updates",
|
"enable_auto_updates",
|
||||||
|
"attach",
|
||||||
|
"detach",
|
||||||
|
"screenshot",
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|||||||
@@ -242,8 +242,15 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
|||||||
abort_button.button.setIcon(
|
abort_button.button.setIcon(
|
||||||
material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False)
|
material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False)
|
||||||
)
|
)
|
||||||
abort_button.button.setStyleSheet("background-color: rgba(0,0,0,0) ")
|
abort_button.setStyleSheet(
|
||||||
abort_button.button.setFlat(True)
|
"""
|
||||||
|
QPushButton {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
return abort_button
|
return abort_button
|
||||||
|
|
||||||
def delete_selected_row(self):
|
def delete_selected_row(self):
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
|
|||||||
|
|
||||||
PLUGIN = True
|
PLUGIN = True
|
||||||
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
|
CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"]
|
||||||
USER_ACCESS = ["get_server_state", "remove"]
|
USER_ACCESS = ["get_server_state", "remove", "attach", "detach", "screenshot"]
|
||||||
|
|
||||||
service_update = Signal(BECServiceInfoContainer)
|
service_update = Signal(BECServiceInfoContainer)
|
||||||
bec_core_state = Signal(str)
|
bec_core_state = Signal(str)
|
||||||
@@ -315,10 +315,10 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
main_window = BECStatusBox()
|
main_window = BECStatusBox()
|
||||||
main_window.show()
|
main_window.show()
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|||||||
@@ -240,10 +240,10 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("light")
|
apply_theme("light")
|
||||||
widget = DeviceBrowser()
|
widget = DeviceBrowser()
|
||||||
widget.show()
|
widget.show()
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|||||||
@@ -262,12 +262,12 @@ def main(): # pragma: no cover
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget
|
from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
dialog = None
|
dialog = None
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("light")
|
apply_theme("light")
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
widget.setLayout(QVBoxLayout())
|
widget.setLayout(QVBoxLayout())
|
||||||
|
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
|
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("light")
|
apply_theme("light")
|
||||||
widget = SignalDisplay(device="samx")
|
widget = SignalDisplay(device="samx")
|
||||||
widget.show()
|
widget.show()
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from qtpy.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector
|
from bec_widgets.utils.bec_connector import BECConnector
|
||||||
from bec_widgets.utils.colors import get_theme_palette, set_theme
|
from bec_widgets.utils.colors import apply_theme, get_theme_palette
|
||||||
from bec_widgets.utils.error_popups import SafeSlot
|
from bec_widgets.utils.error_popups import SafeSlot
|
||||||
from bec_widgets.widgets.editors.text_box.text_box import TextBox
|
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.services.bec_status_box.bec_status_box import BECServiceStatusMixin
|
||||||
@@ -544,7 +544,7 @@ if __name__ == "__main__": # pragma: no cover
|
|||||||
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
set_theme("dark")
|
apply_theme("dark")
|
||||||
widget = LogPanel()
|
widget = LogPanel()
|
||||||
|
|
||||||
widget.show()
|
widget.show()
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class SpinnerWidget(QWidget):
|
|||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
size = min(self.width(), self.height())
|
size = min(self.width(), self.height())
|
||||||
rect = QRect(0, 0, size, size)
|
rect = QRect(0, 0, size, size)
|
||||||
|
|
||||||
@@ -63,14 +63,14 @@ class SpinnerWidget(QWidget):
|
|||||||
rect.adjust(line_width, line_width, -line_width, -line_width)
|
rect.adjust(line_width, line_width, -line_width, -line_width)
|
||||||
|
|
||||||
# Background arc
|
# Background arc
|
||||||
painter.setPen(QPen(background_color, line_width, Qt.SolidLine))
|
painter.setPen(QPen(background_color, line_width, Qt.PenStyle.SolidLine))
|
||||||
adjusted_rect = QRect(rect.left(), rect.top(), rect.width(), rect.height())
|
adjusted_rect = QRect(rect.left(), rect.top(), rect.width(), rect.height())
|
||||||
painter.drawArc(adjusted_rect, 0, 360 * 16)
|
painter.drawArc(adjusted_rect, 0, 360 * 16)
|
||||||
|
|
||||||
if self._started:
|
if self._started:
|
||||||
# Foreground arc
|
# Foreground arc
|
||||||
pen = QPen(color, line_width, Qt.SolidLine)
|
pen = QPen(color, line_width, Qt.PenStyle.SolidLine)
|
||||||
pen.setCapStyle(Qt.RoundCap)
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
painter.setPen(pen)
|
painter.setPen(pen)
|
||||||
proportion = 1 / 4
|
proportion = 1 / 4
|
||||||
angle_span = int(proportion * 360 * 16)
|
angle_span = int(proportion * 360 * 16)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from qtpy.QtCore import Property, Qt, Slot
|
|||||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget
|
from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.bec_widget import BECWidget
|
from bec_widgets.utils.bec_widget import BECWidget
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
|
|
||||||
|
|
||||||
class DarkModeButton(BECWidget, QWidget):
|
class DarkModeButton(BECWidget, QWidget):
|
||||||
@@ -85,7 +85,7 @@ class DarkModeButton(BECWidget, QWidget):
|
|||||||
"""
|
"""
|
||||||
self.dark_mode_enabled = not self.dark_mode_enabled
|
self.dark_mode_enabled = not self.dark_mode_enabled
|
||||||
self.update_mode_button()
|
self.update_mode_button()
|
||||||
set_theme("dark" if self.dark_mode_enabled else "light")
|
apply_theme("dark" if self.dark_mode_enabled else "light")
|
||||||
|
|
||||||
def update_mode_button(self):
|
def update_mode_button(self):
|
||||||
icon = material_icon(
|
icon = material_icon(
|
||||||
@@ -100,7 +100,7 @@ class DarkModeButton(BECWidget, QWidget):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
set_theme("auto")
|
apply_theme("dark")
|
||||||
w = DarkModeButton()
|
w = DarkModeButton()
|
||||||
w.show()
|
w.show()
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bec_ipython_client~=3.52", # needed for jupyter console
|
"bec_ipython_client~=3.52", # needed for jupyter console
|
||||||
"bec_lib~=3.52",
|
"bec_lib~=3.52",
|
||||||
"bec_qthemes~=0.7, >=0.7",
|
"bec_qthemes~=1.0, >=1.1.2",
|
||||||
"black~=25.0", # needed for bw-generate-cli
|
"black~=25.0", # needed for bw-generate-cli
|
||||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||||
"pydantic~=2.0",
|
"pydantic~=2.0",
|
||||||
@@ -24,6 +24,8 @@ dependencies = [
|
|||||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||||
"qtpy~=2.4",
|
"qtpy~=2.4",
|
||||||
"qtmonaco~=0.5",
|
"qtmonaco~=0.5",
|
||||||
|
"darkdetect~=0.8",
|
||||||
|
"PySide6-QtAds==4.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -5,7 +5,9 @@ import h5py
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
from bec_lib import messages
|
from bec_lib import messages
|
||||||
|
from bec_qthemes import apply_theme
|
||||||
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
|
||||||
|
from qtpy.QtCore import QEvent, QEventLoop
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||||
@@ -22,8 +24,18 @@ def pytest_runtest_makereport(item, call):
|
|||||||
item.stash["failed"] = rep.failed
|
item.stash["failed"] = rep.failed
|
||||||
|
|
||||||
|
|
||||||
|
def process_all_deferred_deletes(qapp):
|
||||||
|
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||||
|
qapp.processEvents(QEventLoop.AllEvents)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument
|
def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument
|
||||||
|
qapp = QApplication.instance()
|
||||||
|
process_all_deferred_deletes(qapp)
|
||||||
|
apply_theme("light")
|
||||||
|
qapp.processEvents()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# if the test failed, we don't want to check for open widgets as
|
# if the test failed, we don't want to check for open widgets as
|
||||||
@@ -35,7 +47,6 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus
|
|||||||
bec_dispatcher.stop_cli_server()
|
bec_dispatcher.stop_cli_server()
|
||||||
|
|
||||||
testable_qtimer_class.check_all_stopped(qtbot)
|
testable_qtimer_class.check_all_stopped(qtbot)
|
||||||
qapp = QApplication.instance()
|
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
if hasattr(qapp, "os_listener") and qapp.os_listener:
|
if hasattr(qapp, "os_listener") and qapp.os_listener:
|
||||||
qapp.removeEventFilter(qapp.os_listener)
|
qapp.removeEventFilter(qapp.os_listener)
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ def abort_button(qtbot, mocked_client):
|
|||||||
|
|
||||||
def test_abort_button(abort_button):
|
def test_abort_button(abort_button):
|
||||||
assert abort_button.button.text() == "Abort"
|
assert abort_button.button.text() == "Abort"
|
||||||
assert (
|
|
||||||
abort_button.button.styleSheet()
|
|
||||||
== "background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
|
|
||||||
)
|
|
||||||
abort_button.button.click()
|
abort_button.button.click()
|
||||||
assert abort_button.queue.request_scan_abortion.called
|
assert abort_button.queue.request_scan_abortion.called
|
||||||
abort_button.close()
|
abort_button.close()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import pytest
|
|||||||
from qtpy.QtCore import Qt
|
from qtpy.QtCore import Qt
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import apply_theme
|
||||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||||
|
|
||||||
# pylint: disable=unused-import
|
# pylint: disable=unused-import
|
||||||
@@ -21,7 +21,7 @@ def dark_mode_button(qtbot, mocked_client):
|
|||||||
button = DarkModeButton(client=mocked_client)
|
button = DarkModeButton(client=mocked_client)
|
||||||
qtbot.addWidget(button)
|
qtbot.addWidget(button)
|
||||||
qtbot.waitExposed(button)
|
qtbot.waitExposed(button)
|
||||||
set_theme("light")
|
apply_theme("light")
|
||||||
yield button
|
yield button
|
||||||
|
|
||||||
|
|
||||||
@@ -64,23 +64,10 @@ def test_dark_mode_button_changes_theme(dark_mode_button):
|
|||||||
Test that the dark mode button changes the theme correctly.
|
Test that the dark mode button changes the theme correctly.
|
||||||
"""
|
"""
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.set_theme"
|
"bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.apply_theme"
|
||||||
) as mocked_set_theme:
|
) as mocked_apply_theme:
|
||||||
dark_mode_button.toggle_dark_mode()
|
dark_mode_button.toggle_dark_mode()
|
||||||
mocked_set_theme.assert_called_with("dark")
|
mocked_apply_theme.assert_called_with("dark")
|
||||||
|
|
||||||
dark_mode_button.toggle_dark_mode()
|
dark_mode_button.toggle_dark_mode()
|
||||||
mocked_set_theme.assert_called_with("light")
|
mocked_apply_theme.assert_called_with("light")
|
||||||
|
|
||||||
|
|
||||||
def test_dark_mode_button_changes_on_os_theme_change(qtbot, dark_mode_button):
|
|
||||||
"""
|
|
||||||
Test that the dark mode button changes the theme correctly when the OS theme changes.
|
|
||||||
"""
|
|
||||||
qapp = QApplication.instance()
|
|
||||||
assert dark_mode_button.dark_mode_enabled is False
|
|
||||||
assert dark_mode_button.mode_button.toolTip() == "Set Dark Mode"
|
|
||||||
qapp.theme_signal.theme_updated.emit("dark")
|
|
||||||
qtbot.wait(100)
|
|
||||||
assert dark_mode_button.dark_mode_enabled is True
|
|
||||||
assert dark_mode_button.mode_button.toolTip() == "Set Light Mode"
|
|
||||||
|
|||||||
@@ -42,18 +42,6 @@ def test_set_radius(basic_rounded_frame):
|
|||||||
assert basic_rounded_frame.radius == 20
|
assert basic_rounded_frame.radius == 20
|
||||||
|
|
||||||
|
|
||||||
def test_apply_theme_light(plot_rounded_frame):
|
|
||||||
plot_rounded_frame.apply_theme("light")
|
|
||||||
|
|
||||||
assert plot_rounded_frame.background_color == "#e9ecef"
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_theme_dark(plot_rounded_frame):
|
|
||||||
plot_rounded_frame.apply_theme("dark")
|
|
||||||
|
|
||||||
assert plot_rounded_frame.background_color == "#141414"
|
|
||||||
|
|
||||||
|
|
||||||
def test_apply_plot_widget_style(plot_rounded_frame):
|
def test_apply_plot_widget_style(plot_rounded_frame):
|
||||||
# Verify that a PlotWidget can have its style applied
|
# Verify that a PlotWidget can have its style applied
|
||||||
plot_rounded_frame.apply_plot_widget_style(border="1px solid red")
|
plot_rounded_frame.apply_plot_widget_style(border="1px solid red")
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ def test_multiple_extension_registration():
|
|||||||
"""
|
"""
|
||||||
Test that multiple extension registrations do not cause issues.
|
Test that multiple extension registrations do not cause issues.
|
||||||
"""
|
"""
|
||||||
assert serialization.module_is_registered("bec_widgets.utils.serialization")
|
assert msgpack.is_registered(QPointF)
|
||||||
serialization.register_serializer_extension()
|
serialization.register_serializer_extension()
|
||||||
assert serialization.module_is_registered("bec_widgets.utils.serialization")
|
assert msgpack.is_registered(QPointF)
|
||||||
assert len(msgpack._encoder) == len(set(msgpack._encoder))
|
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ def stop_button(qtbot, mocked_client):
|
|||||||
|
|
||||||
def test_stop_button(stop_button):
|
def test_stop_button(stop_button):
|
||||||
assert stop_button.button.text() == "Stop"
|
assert stop_button.button.text() == "Stop"
|
||||||
assert (
|
|
||||||
stop_button.button.styleSheet()
|
|
||||||
== "background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;"
|
|
||||||
)
|
|
||||||
stop_button.button.click()
|
stop_button.button.click()
|
||||||
assert stop_button.queue.request_scan_halt.called
|
assert stop_button.queue.request_scan_halt.called
|
||||||
stop_button.close()
|
stop_button.close()
|
||||||
|
|||||||