mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-08 01:37:53 +02:00
Compare commits
56 Commits
v2.33.0
...
feat/devic
| Author | SHA1 | Date | |
|---|---|---|---|
| ecad07821c | |||
| 23a26c0722 | |||
| e025632f06 | |||
| 1fb705ee97 | |||
| ad68a1ef8d | |||
| 2ca661e91a | |||
| 578b3ce3b4 | |||
| 1be0c77ad0 | |||
| 7eb2ce5c1d | |||
| 372a243251 | |||
| a27f66bbef | |||
| 66fb0a8816 | |||
| b626a4b4ed | |||
| af21720700 | |||
| 0fff996aae | |||
| 52ef184df1 | |||
| 1ff943a2eb | |||
| b65a2f0d8c | |||
| 963c8127cf | |||
| 43bec1b460 | |||
| 01d9689772 | |||
| 77aaff878b | |||
| 2ef65b3610 | |||
| 5a364eed48 | |||
| 956f2999c2 | |||
| 9c8c3e0cc3 | |||
| 2aa32d150d | |||
|
|
ba047fd776 | ||
| 6e05157abb | |||
|
|
f4bc759e72 | ||
| 1bec9bd9b2 | |||
|
|
8b013d5dce | ||
| f2e5a85e61 | |||
|
|
a2f8880459 | ||
| 926d722955 | |||
| 44ba7201b4 | |||
|
|
0717426db2 | ||
| f4af6ebc5f | |||
| a923f12c97 | |||
| a5a7607a83 | |||
| 9de548446b | |||
| 49ac7decf7 | |||
|
|
092bed38fa | ||
| 50c84a766a | |||
| d22a3317ba | |||
| 6df1d0c31f | |||
| 946752a4b0 | |||
| c1f62ad6cb | |||
| a5adf3a97d | |||
|
|
76e3e0b60f | ||
| f18eeb9c5d | |||
| 32ce8e2818 | |||
| 23413cffab | |||
|
|
4bbb8fa519 | ||
|
|
a972369a72 | ||
| cd81e7f9ba |
109
CHANGELOG.md
109
CHANGELOG.md
@@ -1,6 +1,115 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.38.0 (2025-08-19)
|
||||
|
||||
### Features
|
||||
|
||||
- **device_manager**: Devicemanager view of config session
|
||||
([`6e05157`](https://github.com/bec-project/bec_widgets/commit/6e05157abb61ec569eec10ff7295c28cb6a2ec45))
|
||||
|
||||
|
||||
## v2.37.0 (2025-08-19)
|
||||
|
||||
### Features
|
||||
|
||||
- Add explorer widget
|
||||
([`1bec9bd`](https://github.com/bec-project/bec_widgets/commit/1bec9bd9b2238ed484e8d25e691326efe5730f6b))
|
||||
|
||||
|
||||
## v2.36.0 (2025-08-18)
|
||||
|
||||
### Features
|
||||
|
||||
- **scan control**: Add support for literals
|
||||
([`f2e5a85`](https://github.com/bec-project/bec_widgets/commit/f2e5a85e616aa76d4b7ad3b3c76a24ba114ebdd1))
|
||||
|
||||
|
||||
## v2.35.0 (2025-08-14)
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyside6 upgraded to 6.9.0
|
||||
([`44ba720`](https://github.com/bec-project/bec_widgets/commit/44ba7201b4914d63281bbed5e62d07e5c240595a))
|
||||
|
||||
### Features
|
||||
|
||||
- **property_manager**: Property manager widget
|
||||
([`926d722`](https://github.com/bec-project/bec_widgets/commit/926d7229559d189d382fe034b3afbc544e709efa))
|
||||
|
||||
|
||||
## v2.34.0 (2025-08-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Plugin widget import machinery
|
||||
([`9de5484`](https://github.com/bec-project/bec_widgets/commit/9de548446b9975c0f692757c66ffa07b9a849f15))
|
||||
|
||||
- lazy import client so plugin widgets can import BECWidgets which use it indirectly - exclude
|
||||
classes originating from bec_widgets core from plugin discovery - better errors
|
||||
|
||||
- Use better source for plugin repo name
|
||||
([`f4af6eb`](https://github.com/bec-project/bec_widgets/commit/f4af6ebc5fabf5b62ec87b580476d93d52690b08))
|
||||
|
||||
### Features
|
||||
|
||||
- Autoformat compiled file and add docs
|
||||
([`a923f12`](https://github.com/bec-project/bec_widgets/commit/a923f12c974192909222fcada9eca97325866d74))
|
||||
|
||||
- **plugin manager**: Add cli commands
|
||||
([`49ac7de`](https://github.com/bec-project/bec_widgets/commit/49ac7decf7d4cf461e6437f7285dc6967ee36d96))
|
||||
|
||||
|
||||
## v2.33.3 (2025-07-31)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **scan-history-view**: Account for async loading of scan history
|
||||
([`6df1d0c`](https://github.com/bec-project/bec_widgets/commit/6df1d0c31fb58c25b01e95e2247277ff2dd5d00e))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Improve scan history performance on loading full scan lists
|
||||
([`a5adf3a`](https://github.com/bec-project/bec_widgets/commit/a5adf3a97d9ff05cef833445c1e6cd8f35a9a2fa))
|
||||
|
||||
- Make ids a set, cleanup
|
||||
([`c1f62ad`](https://github.com/bec-project/bec_widgets/commit/c1f62ad6cb00d9b392a8e0b6247f5260dfb37256))
|
||||
|
||||
- Use client callback for scan history reload
|
||||
([`d22a331`](https://github.com/bec-project/bec_widgets/commit/d22a3317baeccfcc4e074dcef4e3912301d210c5))
|
||||
|
||||
- **scan-history**: Add spinner for loading time of history
|
||||
([`50c84a7`](https://github.com/bec-project/bec_widgets/commit/50c84a766a2b021768fb2c0e8ee00b8e5f058ba7))
|
||||
|
||||
- **scan-history**: Fix insert logic; cleanup
|
||||
([`946752a`](https://github.com/bec-project/bec_widgets/commit/946752a4b05804c2f59cb5c21e4c1d11709a7d44))
|
||||
|
||||
|
||||
## v2.33.2 (2025-07-31)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Delete choice dialog on close
|
||||
([`23413cf`](https://github.com/bec-project/bec_widgets/commit/23413cffabe721e35bb5bb726ec34d74dc4ffe05))
|
||||
|
||||
- Display short lists in SignalDisplay
|
||||
([`4bbb8fa`](https://github.com/bec-project/bec_widgets/commit/4bbb8fa519e8a90eebfcfa34e157493c9baa7880))
|
||||
|
||||
- Don't warn on empty DeviceEdit init
|
||||
([`f18eeb9`](https://github.com/bec-project/bec_widgets/commit/f18eeb9c5dccbd9348b6ee6d1477a8b7925d40fc))
|
||||
|
||||
- Remove config, directly set device+signal
|
||||
([`32ce8e2`](https://github.com/bec-project/bec_widgets/commit/32ce8e2818ceacda87e48399e3ed4df0cabb2335))
|
||||
|
||||
|
||||
## v2.33.1 (2025-07-31)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Ensure guis are not started twice
|
||||
([`cd81e7f`](https://github.com/bec-project/bec_widgets/commit/cd81e7f9ba40be23f6b930d250f743276720b277))
|
||||
|
||||
|
||||
## v2.33.0 (2025-07-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
|
||||
if qt_platform != "offscreen":
|
||||
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
||||
|
||||
# Default QtAds configuration
|
||||
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
|
||||
QtAds.CDockManager.setConfigFlag(
|
||||
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
|
||||
)
|
||||
|
||||
__all__ = ["BECWidget", "SafeSlot", "SafeProperty"]
|
||||
|
||||
@@ -106,6 +106,99 @@ class AbortButton(RPCBase):
|
||||
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):
|
||||
@property
|
||||
@@ -442,6 +535,18 @@ class BECMainWindow(RPCBase):
|
||||
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):
|
||||
"""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
|
||||
"""
|
||||
|
||||
@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):
|
||||
"""An autonomous widget to display the status of BEC services."""
|
||||
@@ -541,6 +658,25 @@ class BECStatusBox(RPCBase):
|
||||
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):
|
||||
"""Base class for all Region of Interest (ROI) implementations."""
|
||||
@@ -1002,6 +1138,18 @@ class DarkModeButton(RPCBase):
|
||||
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):
|
||||
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
|
||||
@@ -1012,6 +1160,18 @@ class DeviceBrowser(RPCBase):
|
||||
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):
|
||||
"""Combobox widget for device input with autocomplete for device names."""
|
||||
@@ -1045,6 +1205,18 @@ class DeviceInputBase(RPCBase):
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
@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):
|
||||
@@ -1978,6 +2162,18 @@ class Image(RPCBase):
|
||||
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_call
|
||||
def screenshot(self, file_name: "str | None" = None):
|
||||
@@ -2590,6 +2786,25 @@ class MonacoWidget(RPCBase):
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
@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):
|
||||
@@ -3277,6 +3504,18 @@ class MultiWaveform(RPCBase):
|
||||
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_call
|
||||
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
|
||||
"""
|
||||
|
||||
@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):
|
||||
@@ -3527,6 +3778,18 @@ class PositionerBox2D(RPCBase):
|
||||
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_call
|
||||
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
|
||||
"""
|
||||
|
||||
@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):
|
||||
@@ -3566,6 +3841,25 @@ class PositionerGroup(RPCBase):
|
||||
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):
|
||||
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
|
||||
@@ -3705,6 +3999,18 @@ class ResetButton(RPCBase):
|
||||
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):
|
||||
"""A button that continue scan queue."""
|
||||
@@ -3715,6 +4021,18 @@ class ResumeButton(RPCBase):
|
||||
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):
|
||||
@rpc_call
|
||||
@@ -3996,6 +4314,25 @@ class RingProgressBar(RPCBase):
|
||||
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):
|
||||
"""A widget to display the SBB monitor website."""
|
||||
@@ -4007,9 +4344,15 @@ class ScanControl(RPCBase):
|
||||
"""Widget to submit new scans to the queue."""
|
||||
|
||||
@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)
|
||||
@@ -4029,6 +4372,18 @@ class ScanProgressBar(RPCBase):
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
@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):
|
||||
@@ -4563,6 +4930,20 @@ class SignalLabel(RPCBase):
|
||||
Displays the full data from array signals if set to True.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def max_list_display_len(self) -> "int":
|
||||
"""
|
||||
For small lists, the max length to display
|
||||
"""
|
||||
|
||||
@max_list_display_len.setter
|
||||
@rpc_call
|
||||
def max_list_display_len(self) -> "int":
|
||||
"""
|
||||
For small lists, the max length to display
|
||||
"""
|
||||
|
||||
|
||||
class SignalLineEdit(RPCBase):
|
||||
"""Line edit widget for device input with autocomplete for device names."""
|
||||
@@ -4615,6 +4996,18 @@ class StopButton(RPCBase):
|
||||
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):
|
||||
"""A widget that displays text in plain and HTML format"""
|
||||
@@ -4647,6 +5040,25 @@ class VSCodeEditor(RPCBase):
|
||||
class Waveform(RPCBase):
|
||||
"""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
|
||||
@rpc_call
|
||||
def _config_dict(self) -> "dict":
|
||||
@@ -4951,13 +5363,6 @@ class Waveform(RPCBase):
|
||||
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
|
||||
@rpc_call
|
||||
def curves(self) -> "list[Curve]":
|
||||
@@ -5199,6 +5604,18 @@ class WebConsole(RPCBase):
|
||||
Cleanup the BECConnector
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def attach(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def detach(self):
|
||||
"""
|
||||
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
|
||||
"""
|
||||
|
||||
|
||||
class WebsiteWidget(RPCBase):
|
||||
"""A simple widget to display a website"""
|
||||
@@ -5238,3 +5655,22 @@ class WebsiteWidget(RPCBase):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -14,18 +14,21 @@ from typing import TYPE_CHECKING, Literal, TypeAlias, cast
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import_from
|
||||
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
|
||||
from bec_widgets.utils.serialization import register_serializer_extension
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_lib.messages import GUIRegistryStateMessage
|
||||
|
||||
import bec_widgets.cli.client as client
|
||||
else:
|
||||
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
|
||||
client = lazy_import("bec_widgets.cli.client")
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
@@ -151,8 +154,10 @@ def wait_for_server(client: BECGuiClient):
|
||||
raise RuntimeError("GUI is not alive")
|
||||
try:
|
||||
if client._gui_started_event.wait(timeout=timeout):
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
if client._gui_started_timer is not None:
|
||||
# cancel the timer, we are done
|
||||
client._gui_started_timer.cancel()
|
||||
client._gui_started_timer.join()
|
||||
else:
|
||||
raise TimeoutError("Could not connect to GUI server")
|
||||
finally:
|
||||
@@ -261,13 +266,20 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def start(self, wait: bool = False) -> None:
|
||||
"""Start the GUI server."""
|
||||
logger.warning("Using <gui>.start() is deprecated, use <gui>.show() instead.")
|
||||
return self._start(wait=wait)
|
||||
|
||||
def show(self):
|
||||
"""Show the GUI window."""
|
||||
def show(self, wait=True) -> None:
|
||||
"""
|
||||
Show the GUI window.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
wait(bool): Whether to wait for the server to start. Defaults to True.
|
||||
"""
|
||||
if self._check_if_server_is_alive():
|
||||
return self._show_all()
|
||||
return self.start(wait=True)
|
||||
return self._start(wait=wait)
|
||||
|
||||
def hide(self):
|
||||
"""Hide the GUI window."""
|
||||
@@ -382,6 +394,9 @@ class BECGuiClient(RPCBase):
|
||||
"""
|
||||
Start the GUI server, and execute callback when it is launched
|
||||
"""
|
||||
if self._gui_is_alive():
|
||||
self._gui_started_event.set()
|
||||
return
|
||||
if self._process is None or self._process.poll() is not None:
|
||||
logger.success("GUI starting...")
|
||||
self._startup_timeout = 5
|
||||
@@ -524,7 +539,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
# Test the client_utils.py module
|
||||
gui = BECGuiClient()
|
||||
|
||||
gui.start(wait=True)
|
||||
gui.show(wait=True)
|
||||
gui.new().new(widget="Waveform")
|
||||
time.sleep(10)
|
||||
finally:
|
||||
|
||||
214
bec_widgets/examples/device_manager_view/device_manager_view.py
Normal file
214
bec_widgets/examples/device_manager_view/device_manager_view.py
Normal file
@@ -0,0 +1,214 @@
|
||||
from typing import List
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
import yaml
|
||||
from bec_qthemes import material_icon
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QStackedLayout,
|
||||
QTreeWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
|
||||
AvailableDeviceResources,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.device_table_view import DeviceTableView
|
||||
from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView
|
||||
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import (
|
||||
DeviceManagerOphydTest,
|
||||
)
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
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, *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)
|
||||
|
||||
# Initialize the widgets
|
||||
self.available_devices = AvailableDeviceResources(self)
|
||||
self.device_table_view = DeviceTableView(self)
|
||||
# Placeholder
|
||||
self.dm_config_view = DMConfigView(self)
|
||||
|
||||
# Placeholder for ophyd test
|
||||
WebConsole.startup_cmd = "ipython"
|
||||
self.ophyd_test = DeviceManagerOphydTest(self)
|
||||
self.ophyd_test_dock = QtAds.CDockWidget("Ophyd Test", self)
|
||||
self.ophyd_test_dock.setWidget(self.ophyd_test)
|
||||
|
||||
# Create the dock widgets
|
||||
self.available_devices_dock = QtAds.CDockWidget("Explorer", self)
|
||||
self.available_devices_dock.setWidget(self.available_devices)
|
||||
|
||||
self.device_table_view_dock = QtAds.CDockWidget("Device Table", self)
|
||||
self.device_table_view_dock.setWidget(self.device_table_view)
|
||||
|
||||
# Device Table will be central widget
|
||||
self.dock_manager.setCentralWidget(self.device_table_view_dock)
|
||||
|
||||
self.dm_config_view_dock = QtAds.CDockWidget("YAML Editor", self)
|
||||
self.dm_config_view_dock.setWidget(self.dm_config_view)
|
||||
|
||||
# Add the dock widgets to the dock manager
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock
|
||||
)
|
||||
monaco_yaml_area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea, self.dm_config_view_dock
|
||||
)
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea, self.ophyd_test_dock, monaco_yaml_area
|
||||
)
|
||||
|
||||
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, 5, 3], [5, 5])
|
||||
|
||||
# Connect slots
|
||||
self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config)
|
||||
self.device_table_view.model.devices_reset.connect(
|
||||
self.available_devices.update_devices_state
|
||||
)
|
||||
|
||||
####### 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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
device_manager_view = DeviceManagerView()
|
||||
config = device_manager_view.client.device_manager._get_redis_device_config()
|
||||
device_manager_view.device_table_view.set_device_config(config)
|
||||
device_manager_view.show()
|
||||
device_manager_view.setWindowTitle("Device Manager View")
|
||||
device_manager_view.resize(1200, 800)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Top Level wrapper for device_manager widget"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
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)
|
||||
self._overlay_widget.setVisible(True)
|
||||
|
||||
@SafeSlot()
|
||||
def _load_config_clicked(self):
|
||||
"""Handle click on 'Load Current Config' button."""
|
||||
config = self.client.device_manager._get_redis_device_config()
|
||||
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_())
|
||||
@@ -16,6 +16,7 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from bec_widgets.utils import BECDispatcher
|
||||
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.layout_manager.layout_manager import LayoutManagerWidget
|
||||
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
|
||||
@@ -44,6 +45,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
"wh": wh,
|
||||
"dock": self.dock,
|
||||
"im": self.im,
|
||||
"ads": self.ads,
|
||||
# "mi": self.mi,
|
||||
# "mm": self.mm,
|
||||
# "lm": self.lm,
|
||||
@@ -120,14 +122,12 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
|
||||
tab_widget.addTab(sixth_tab, "Image Next Gen")
|
||||
tab_widget.setCurrentIndex(1)
|
||||
#
|
||||
# seventh_tab = QWidget()
|
||||
# seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
# self.scatter = ScatterWaveform()
|
||||
# self.scatter_mi = self.scatter.main_curve
|
||||
# self.scatter.plot("samx", "samy", "bpm4i")
|
||||
# seventh_tab_layout.addWidget(self.scatter)
|
||||
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
|
||||
# tab_widget.setCurrentIndex(6)
|
||||
seventh_tab = QWidget()
|
||||
seventh_tab_layout = QVBoxLayout(seventh_tab)
|
||||
self.ads = AdvancedDockArea(gui_id="ads")
|
||||
seventh_tab_layout.addWidget(self.ads)
|
||||
tab_widget.addTab(seventh_tab, "ADS")
|
||||
tab_widget.setCurrentIndex(2)
|
||||
#
|
||||
# eighth_tab = QWidget()
|
||||
# eighth_tab_layout = QVBoxLayout(eighth_tab)
|
||||
|
||||
@@ -77,6 +77,8 @@ class BECConnector:
|
||||
|
||||
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
|
||||
EXIT_HANDLERS = {}
|
||||
widget_removed = Signal()
|
||||
name_established = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -204,6 +206,10 @@ class BECConnector:
|
||||
self._enforce_unique_sibling_name()
|
||||
# 2) Register the object for RPC
|
||||
self.rpc_register.add_rpc(self)
|
||||
try:
|
||||
self.name_established.emit(self.object_name)
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
def _enforce_unique_sibling_name(self):
|
||||
"""
|
||||
@@ -450,6 +456,7 @@ class BECConnector:
|
||||
# i.e. Curve Item from Waveform
|
||||
else:
|
||||
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:
|
||||
"""
|
||||
|
||||
@@ -38,9 +38,11 @@ def _loaded_submodules_from_specs(
|
||||
try:
|
||||
submodule.__loader__.exec_module(submodule)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error loading plugin {submodule}: \n{''.join(traceback.format_exception(e))}"
|
||||
)
|
||||
exception_text = "".join(traceback.format_exception(e))
|
||||
if "(most likely due to a circular import)" in exception_text:
|
||||
logger.warning(f"Circular import encountered while loading {submodule}")
|
||||
else:
|
||||
logger.error(f"Error loading plugin {submodule}: \n{exception_text}")
|
||||
yield submodule
|
||||
|
||||
|
||||
@@ -59,7 +61,8 @@ def _get_widgets_from_module(module: ModuleType) -> BECClassContainer:
|
||||
module,
|
||||
predicate=lambda item: inspect.isclass(item)
|
||||
and issubclass(item, BECWidget)
|
||||
and item is not BECWidget,
|
||||
and item is not BECWidget
|
||||
and not item.__module__.startswith("bec_widgets"),
|
||||
)
|
||||
return BECClassContainer(
|
||||
BECClassInfo(name=k, module=module.__name__, file=module.__loader__.get_filename(), obj=v)
|
||||
|
||||
0
bec_widgets/utils/bec_plugin_manager/__init__.py
Normal file
0
bec_widgets/utils/bec_plugin_manager/__init__.py
Normal file
86
bec_widgets/utils/bec_plugin_manager/create/widget.py
Normal file
86
bec_widgets/utils/bec_plugin_manager/create/widget.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import copier
|
||||
import typer
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_repo_path
|
||||
from bec_lib.utils.plugin_manager._constants import ANSWER_KEYS
|
||||
from bec_lib.utils.plugin_manager._util import existing_data, git_stage_files, make_commit
|
||||
|
||||
from bec_widgets.utils.bec_plugin_manager.edit_ui import open_and_watch_ui_editor
|
||||
|
||||
logger = bec_logger.logger
|
||||
_app = typer.Typer(rich_markup_mode="rich")
|
||||
|
||||
|
||||
def _commit_added_widget(repo: Path, name: str):
|
||||
git_stage_files(repo, [".copier-answers.yml"])
|
||||
git_stage_files(repo / repo.name / "bec_widgets" / "widgets" / name, [])
|
||||
make_commit(repo, f"plugin-manager added new widget: {name}")
|
||||
logger.info(f"Committing new widget {name}")
|
||||
|
||||
|
||||
def _widget_exists(widget_list: list[dict[str, str | bool]], name: str):
|
||||
return name in [w["name"] for w in widget_list]
|
||||
|
||||
|
||||
def _editor_cb(ctx: typer.Context, value: bool):
|
||||
if value and not ctx.params["use_ui"]:
|
||||
raise typer.BadParameter("Can only open the editor if creating a .ui file!")
|
||||
return value
|
||||
|
||||
|
||||
_bold_blue = "\033[34m\033[1m"
|
||||
_off = "\033[0m"
|
||||
_USE_UI_MSG = "Generate a .ui file for use in bec-designer."
|
||||
_OPEN_DESIGNER_MSG = f"""This app can watch for changes and recompile them to a python file imported to the widget whenever it is saved.
|
||||
To open this editor independently, you can use {_bold_blue}bec-plugin-manager edit-ui [widget_name]{_off}.
|
||||
Open the created widget .ui file in bec-designer now?"""
|
||||
|
||||
|
||||
@_app.command()
|
||||
def widget(
|
||||
name: Annotated[str, typer.Argument(help="Enter a name for your widget in snake_case")],
|
||||
use_ui: Annotated[bool, typer.Option(prompt=_USE_UI_MSG, help=_USE_UI_MSG)] = True,
|
||||
open_editor: Annotated[
|
||||
bool, typer.Option(prompt=_OPEN_DESIGNER_MSG, help=_OPEN_DESIGNER_MSG, callback=_editor_cb)
|
||||
] = True,
|
||||
):
|
||||
"""Create a new widget plugin with the given name.
|
||||
|
||||
If [bold white]use_ui[/bold white] is set, a bec-designer .ui file will also be created. If \
|
||||
[bold white]open_editor[/bold white] is additionally set, the .ui file will be opened in \
|
||||
bec-designer and the compiled python version will be updated when changes are made and saved."""
|
||||
if (formatted_name := name.lower().replace("-", "_")) != name:
|
||||
logger.warning(f"Adjusting widget name from {name} to {formatted_name}")
|
||||
if not formatted_name.isidentifier():
|
||||
logger.error(
|
||||
f"{name} is not a valid name for a widget (even after converting to {formatted_name}) - please enter something in snake_case"
|
||||
)
|
||||
exit(-1)
|
||||
logger.info(f"Adding new widget {formatted_name} to the template...")
|
||||
try:
|
||||
repo = Path(plugin_repo_path())
|
||||
plugin_data = existing_data(repo, [ANSWER_KEYS.VERSION, ANSWER_KEYS.WIDGETS])
|
||||
if _widget_exists(plugin_data[ANSWER_KEYS.WIDGETS], formatted_name):
|
||||
logger.error(f"Widget {formatted_name} already exists!")
|
||||
exit(-1)
|
||||
plugin_data[ANSWER_KEYS.WIDGETS].append({"name": formatted_name, "use_ui": use_ui})
|
||||
copier.run_update(
|
||||
repo,
|
||||
data=plugin_data,
|
||||
defaults=True,
|
||||
unsafe=True,
|
||||
overwrite=True,
|
||||
vcs_ref=plugin_data[ANSWER_KEYS.VERSION],
|
||||
)
|
||||
_commit_added_widget(repo, formatted_name)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error("exiting...")
|
||||
exit(-1)
|
||||
logger.success(f"Added widget {formatted_name}!")
|
||||
if open_editor:
|
||||
open_and_watch_ui_editor(formatted_name)
|
||||
136
bec_widgets/utils/bec_plugin_manager/edit_ui.py
Normal file
136
bec_widgets/utils/bec_plugin_manager/edit_ui.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from watchdog.events import (
|
||||
DirCreatedEvent,
|
||||
DirModifiedEvent,
|
||||
DirMovedEvent,
|
||||
FileCreatedEvent,
|
||||
FileModifiedEvent,
|
||||
FileMovedEvent,
|
||||
FileSystemEvent,
|
||||
FileSystemEventHandler,
|
||||
)
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from bec_widgets.utils.bec_designer import open_designer
|
||||
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
|
||||
from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class RecompileHandler(FileSystemEventHandler):
|
||||
def __init__(self, in_file: Path, out_file: Path) -> None:
|
||||
super().__init__()
|
||||
self.in_file = str(in_file)
|
||||
self.out_file = str(out_file)
|
||||
self._pyside_import_re = re.compile(r"from PySide6\.(.*) import ")
|
||||
self._widget_import_re = re.compile(
|
||||
r"^from ([a-zA-Z_]*) import ([a-zA-Z_]*)$", re.MULTILINE
|
||||
)
|
||||
self._widget_modules = {
|
||||
c.name: c.module for c in (get_custom_classes("bec_widgets") + get_all_plugin_widgets())
|
||||
}
|
||||
|
||||
def on_created(self, event: DirCreatedEvent | FileCreatedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def on_modified(self, event: DirModifiedEvent | FileModifiedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def on_moved(self, event: DirMovedEvent | FileMovedEvent) -> None:
|
||||
self.recompile(event)
|
||||
|
||||
def recompile(self, event: FileSystemEvent) -> None:
|
||||
if event.src_path == self.in_file or event.dest_path == self.in_file:
|
||||
self._recompile()
|
||||
|
||||
def _recompile(self):
|
||||
logger.success(".ui file modified, recompiling...")
|
||||
code = subprocess.call(
|
||||
["pyside6-uic", "--absolute-imports", self.in_file, "-o", self.out_file]
|
||||
)
|
||||
logger.success(f"compilation exited with code {code}")
|
||||
if code != 0:
|
||||
return
|
||||
self._add_comment_to_file()
|
||||
logger.success("updating imports...")
|
||||
self._update_imports()
|
||||
logger.success("formatting...")
|
||||
code = subprocess.call(
|
||||
["black", "--line-length=100", "--skip-magic-trailing-comma", self.out_file]
|
||||
)
|
||||
if code != 0:
|
||||
logger.error(f"Error while running black on {self.out_file}, code: {code}")
|
||||
return
|
||||
code = subprocess.call(
|
||||
[
|
||||
"isort",
|
||||
"--line-length=100",
|
||||
"--profile=black",
|
||||
"--multi-line=3",
|
||||
"--trailing-comma",
|
||||
self.out_file,
|
||||
]
|
||||
)
|
||||
if code != 0:
|
||||
logger.error(f"Error while running isort on {self.out_file}, code: {code}")
|
||||
return
|
||||
logger.success("done!")
|
||||
|
||||
def _add_comment_to_file(self):
|
||||
with open(self.out_file, "r+") as f:
|
||||
initial = f.read()
|
||||
f.seek(0)
|
||||
f.write(f"# Generated from {self.in_file} by bec-plugin-manager - do not edit! \n")
|
||||
f.write(
|
||||
"# Use 'bec-plugin-manager edit-ui [widget_name]' to make changes, and this file will be updated accordingly. \n\n"
|
||||
)
|
||||
f.write(initial)
|
||||
|
||||
def _update_imports(self):
|
||||
with open(self.out_file, "r+") as f:
|
||||
initial = f.read()
|
||||
f.seek(0)
|
||||
qtpy_imports = re.sub(
|
||||
self._pyside_import_re, lambda ob: f"from qtpy.{ob.group(1)} import ", initial
|
||||
)
|
||||
print(self._widget_modules)
|
||||
print(re.findall(self._widget_import_re, qtpy_imports))
|
||||
widget_imports = re.sub(
|
||||
self._widget_import_re,
|
||||
lambda ob: (
|
||||
f"from {module} import {ob.group(2)}"
|
||||
if (module := self._widget_modules.get(ob.group(2))) is not None
|
||||
else ob.group(1)
|
||||
),
|
||||
qtpy_imports,
|
||||
)
|
||||
f.write(widget_imports)
|
||||
f.truncate()
|
||||
|
||||
|
||||
def open_and_watch_ui_editor(widget_name: str):
|
||||
logger.info(f"Opening the editor for {widget_name}, and watching")
|
||||
repo = Path(plugin_repo_path())
|
||||
widget_dir = repo / plugin_package_name() / "bec_widgets" / "widgets" / widget_name
|
||||
ui_file = widget_dir / f"{widget_name}.ui"
|
||||
ui_outfile = widget_dir / f"{widget_name}_ui.py"
|
||||
|
||||
logger.info(
|
||||
f"Opening the editor for {widget_name}, and watching {ui_file} for changes. Whenever you save the file, it will be recompiled to {ui_outfile}"
|
||||
)
|
||||
recompile_handler = RecompileHandler(ui_file, ui_outfile)
|
||||
observer = Observer()
|
||||
observer.schedule(recompile_handler, str(ui_file.parent))
|
||||
observer.start()
|
||||
try:
|
||||
open_designer([str(ui_file)])
|
||||
finally:
|
||||
observer.stop()
|
||||
observer.join()
|
||||
logger.info("Editing session ended, exiting...")
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import darkdetect
|
||||
import PySide6QtAds as QtAds
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QObject
|
||||
@@ -14,6 +15,7 @@ from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets.utils.colors import set_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from bec_widgets.widgets.containers.dock import BECDock
|
||||
@@ -27,7 +29,7 @@ class BECWidget(BECConnector):
|
||||
# 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.
|
||||
ICON_NAME = "widgets"
|
||||
USER_ACCESS = ["remove"]
|
||||
USER_ACCESS = ["remove", "attach", "detach"]
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(
|
||||
@@ -124,6 +126,26 @@ class BECWidget(BECConnector):
|
||||
screenshot.save(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):
|
||||
"""Cleanup the widget."""
|
||||
with RPCRegister.delayed_broadcast():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -19,7 +19,8 @@ from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
||||
class ExpandableGroupFrame(QFrame):
|
||||
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
imminent_deletion = Signal()
|
||||
expansion_state_changed = Signal()
|
||||
|
||||
EXPANDED_ICON_NAME: str = "collapse_all"
|
||||
@@ -31,6 +32,7 @@ class ExpandableGroupFrame(QFrame):
|
||||
super().__init__(parent=parent)
|
||||
self._expanded = expanded
|
||||
|
||||
self._title_text = f"<b>{title}</b>"
|
||||
self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
self._layout = QVBoxLayout()
|
||||
@@ -49,21 +51,27 @@ class ExpandableGroupFrame(QFrame):
|
||||
def _create_title_layout(self, title: str, icon: str):
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._layout.addLayout(self._title_layout)
|
||||
self._internal_title_layout = QHBoxLayout()
|
||||
self._title_layout.addLayout(self._internal_title_layout)
|
||||
|
||||
self._title = ClickableLabel(f"<b>{title}</b>")
|
||||
self._title = ClickableLabel()
|
||||
self._set_title_text(self._title_text)
|
||||
self._title_icon = ClickableLabel()
|
||||
self._title_layout.addWidget(self._title_icon)
|
||||
self._title_layout.addWidget(self._title)
|
||||
self._internal_title_layout.addWidget(self._title_icon)
|
||||
self._internal_title_layout.addWidget(self._title)
|
||||
self.icon_name = icon
|
||||
self._title.clicked.connect(self.switch_expanded_state)
|
||||
self._title_icon.clicked.connect(self.switch_expanded_state)
|
||||
|
||||
self._title_layout.addStretch(1)
|
||||
self._internal_title_layout.addStretch(1)
|
||||
|
||||
self._expansion_button = QToolButton()
|
||||
self._update_expansion_icon()
|
||||
self._title_layout.addWidget(self._expansion_button, stretch=1)
|
||||
|
||||
def get_title_layout(self) -> QHBoxLayout:
|
||||
return self._internal_title_layout
|
||||
|
||||
def set_layout(self, layout: QLayout) -> None:
|
||||
self._contents.setLayout(layout)
|
||||
self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore
|
||||
@@ -112,6 +120,18 @@ class ExpandableGroupFrame(QFrame):
|
||||
else:
|
||||
self._title_icon.setVisible(False)
|
||||
|
||||
@SafeProperty(str)
|
||||
def title_text(self): # type: ignore
|
||||
return self._title_text
|
||||
|
||||
@title_text.setter
|
||||
def title_text(self, title_text: str):
|
||||
self._title_text = title_text
|
||||
self._set_title_text(self._title_text)
|
||||
|
||||
def _set_title_text(self, title_text: str):
|
||||
self._title.setText(title_text)
|
||||
|
||||
|
||||
# Application example
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
81
bec_widgets/utils/list_of_expandable_frames.py
Normal file
81
bec_widgets/utils/list_of_expandable_frames.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from functools import partial
|
||||
from typing import Generic, Iterable, NamedTuple, TypeVar
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from PySide6.QtWidgets import QListWidgetItem, QWidget
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QListWidget
|
||||
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
_EF = TypeVar("_EF", bound=ExpandableGroupFrame)
|
||||
|
||||
|
||||
class ListOfExpandableFrames(QListWidget, Generic[_EF]):
|
||||
def __init__(
|
||||
self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
_Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF)))
|
||||
self.item_tuple = _Items
|
||||
self._item_class = item_class
|
||||
self._item_dict: dict[str, _Items] = {}
|
||||
|
||||
def __contains__(self, id: str):
|
||||
return id in self._item_dict
|
||||
|
||||
def clear(self) -> None:
|
||||
self._item_dict = {}
|
||||
return super().clear()
|
||||
|
||||
def add_item(self, id: str, *args, **kwargs) -> _EF:
|
||||
"""Adds the specified type of widget as an item. args and kwargs are passed to the constructor.
|
||||
|
||||
Args:
|
||||
id (str): the key under which to store the list item in the internal dict
|
||||
|
||||
Returns:
|
||||
The widget created in the addition process
|
||||
"""
|
||||
|
||||
def _remove_item(item: QListWidgetItem):
|
||||
self.takeItem(self.row(item))
|
||||
del self._item_dict[id]
|
||||
self.sortItems()
|
||||
|
||||
def _updatesize(item: QListWidgetItem, item_widget: _EF):
|
||||
item_widget.adjustSize()
|
||||
item.setSizeHint(QSize(item_widget.width(), item_widget.height()))
|
||||
|
||||
item = QListWidgetItem(self)
|
||||
item_widget = self._item_class(*args, **kwargs)
|
||||
|
||||
item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget))
|
||||
item_widget.imminent_deletion.connect(partial(_remove_item, item))
|
||||
item_widget.broadcast_size_hint.connect(item.setSizeHint)
|
||||
|
||||
self.setItemWidget(item, item_widget)
|
||||
self.addItem(item)
|
||||
self._item_dict[id] = self.item_tuple(item, item_widget)
|
||||
|
||||
item.setSizeHint(item_widget.sizeHint())
|
||||
return item_widget
|
||||
|
||||
def get_item_widget(self, id: str):
|
||||
if (item := self._item_dict.get(id)) is None:
|
||||
return None
|
||||
return item
|
||||
|
||||
def set_hidden(self, ids: Iterable[str]):
|
||||
for id in ids:
|
||||
if (_item := self._item_dict.get(id)) is not None:
|
||||
_item.widget.setHidden(True)
|
||||
else:
|
||||
logger.warning(
|
||||
f"List {self.__qualname__} does not have an item with ID {id} to hide!"
|
||||
)
|
||||
|
||||
def unhide_all(self):
|
||||
map(lambda i: i.widget.setHidden(False), self._item_dict.values())
|
||||
694
bec_widgets/utils/property_editor.py
Normal file
694
bec_widgets/utils/property_editor.py
Normal file
@@ -0,0 +1,694 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QLocale, QMetaEnum, Qt, QTimer
|
||||
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFontDialog,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class PropertyEditor(QWidget):
|
||||
def __init__(self, target: QWidget, parent: QWidget | None = None, show_only_bec: bool = True):
|
||||
super().__init__(parent)
|
||||
self._target = target
|
||||
self._bec_only = show_only_bec
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Name row
|
||||
name_row = QHBoxLayout()
|
||||
name_row.addWidget(QLabel("Name:"))
|
||||
self.name_edit = QLineEdit(target.objectName())
|
||||
self.name_edit.setEnabled(False) # TODO implement with RPC broadcast
|
||||
name_row.addWidget(self.name_edit)
|
||||
layout.addLayout(name_row)
|
||||
|
||||
# BEC only checkbox
|
||||
filter_row = QHBoxLayout()
|
||||
self.chk_show_qt = QCheckBox("Show Qt properties")
|
||||
self.chk_show_qt.setChecked(False)
|
||||
filter_row.addWidget(self.chk_show_qt)
|
||||
filter_row.addStretch(1)
|
||||
layout.addLayout(filter_row)
|
||||
self.chk_show_qt.toggled.connect(lambda checked: self.set_show_only_bec(not checked))
|
||||
|
||||
# Main tree widget
|
||||
self.tree = QTreeWidget(self)
|
||||
self.tree.setColumnCount(2)
|
||||
self.tree.setHeaderLabels(["Property", "Value"])
|
||||
self.tree.setAlternatingRowColors(True)
|
||||
self.tree.setRootIsDecorated(False)
|
||||
layout.addWidget(self.tree)
|
||||
self._build()
|
||||
|
||||
def _class_chain(self):
|
||||
chain = []
|
||||
mo = self._target.metaObject()
|
||||
while mo is not None:
|
||||
chain.append(mo)
|
||||
mo = mo.superClass()
|
||||
return chain
|
||||
|
||||
def set_show_only_bec(self, flag: bool):
|
||||
self._bec_only = flag
|
||||
self._build()
|
||||
|
||||
def _set_equal_columns(self):
|
||||
header = self.tree.header()
|
||||
header.setSectionResizeMode(0, QHeaderView.Interactive)
|
||||
header.setSectionResizeMode(1, QHeaderView.Interactive)
|
||||
w = self.tree.viewport().width() or self.tree.width()
|
||||
if w > 0:
|
||||
half = max(1, w // 2)
|
||||
self.tree.setColumnWidth(0, half)
|
||||
self.tree.setColumnWidth(1, w - half)
|
||||
|
||||
def _build(self):
|
||||
self.tree.clear()
|
||||
for mo in self._class_chain():
|
||||
class_name = mo.className()
|
||||
if self._bec_only and not self._is_bec_metaobject(mo):
|
||||
continue
|
||||
group_item = QTreeWidgetItem(self.tree, [class_name])
|
||||
group_item.setFirstColumnSpanned(True)
|
||||
start = mo.propertyOffset()
|
||||
end = mo.propertyCount()
|
||||
for i in range(start, end):
|
||||
prop = mo.property(i)
|
||||
if (
|
||||
not prop.isReadable()
|
||||
or not prop.isWritable()
|
||||
or not prop.isStored()
|
||||
or not prop.isDesignable()
|
||||
):
|
||||
continue
|
||||
name = prop.name()
|
||||
if name == "objectName":
|
||||
continue
|
||||
value = self._target.property(name)
|
||||
self._add_property_row(group_item, name, value, prop)
|
||||
if group_item.childCount() == 0:
|
||||
idx = self.tree.indexOfTopLevelItem(group_item)
|
||||
self.tree.takeTopLevelItem(idx)
|
||||
self.tree.expandAll()
|
||||
QTimer.singleShot(0, self._set_equal_columns)
|
||||
|
||||
def _enum_int(self, obj) -> int:
|
||||
return int(getattr(obj, "value", obj))
|
||||
|
||||
def _make_sizepolicy_editor(self, name: str, sp):
|
||||
if not isinstance(sp, QSizePolicy):
|
||||
return None
|
||||
wrap = QWidget(self)
|
||||
row = QHBoxLayout(wrap)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
h_combo = QComboBox(wrap)
|
||||
v_combo = QComboBox(wrap)
|
||||
hs = QSpinBox(wrap)
|
||||
vs = QSpinBox(wrap)
|
||||
for b in (hs, vs):
|
||||
b.setRange(0, 16777215)
|
||||
policies = [
|
||||
(QSizePolicy.Fixed, "Fixed"),
|
||||
(QSizePolicy.Minimum, "Minimum"),
|
||||
(QSizePolicy.Maximum, "Maximum"),
|
||||
(QSizePolicy.Preferred, "Preferred"),
|
||||
(QSizePolicy.Expanding, "Expanding"),
|
||||
(QSizePolicy.MinimumExpanding, "MinExpanding"),
|
||||
(QSizePolicy.Ignored, "Ignored"),
|
||||
]
|
||||
for pol, text in policies:
|
||||
h_combo.addItem(text, self._enum_int(pol))
|
||||
v_combo.addItem(text, self._enum_int(pol))
|
||||
|
||||
def _set_current(combo, val):
|
||||
idx = combo.findData(self._enum_int(val))
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
_set_current(h_combo, sp.horizontalPolicy())
|
||||
_set_current(v_combo, sp.verticalPolicy())
|
||||
hs.setValue(sp.horizontalStretch())
|
||||
vs.setValue(sp.verticalStretch())
|
||||
|
||||
def apply_changes():
|
||||
hp = QSizePolicy.Policy(h_combo.currentData())
|
||||
vp = QSizePolicy.Policy(v_combo.currentData())
|
||||
nsp = QSizePolicy(hp, vp)
|
||||
nsp.setHorizontalStretch(hs.value())
|
||||
nsp.setVerticalStretch(vs.value())
|
||||
self._target.setProperty(name, nsp)
|
||||
|
||||
h_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
|
||||
v_combo.currentIndexChanged.connect(lambda _=None: apply_changes())
|
||||
hs.valueChanged.connect(lambda _=None: apply_changes())
|
||||
vs.valueChanged.connect(lambda _=None: apply_changes())
|
||||
row.addWidget(h_combo)
|
||||
row.addWidget(v_combo)
|
||||
row.addWidget(hs)
|
||||
row.addWidget(vs)
|
||||
return wrap
|
||||
|
||||
def _make_locale_editor(self, name: str, loc):
|
||||
if not isinstance(loc, QLocale):
|
||||
return None
|
||||
wrap = QWidget(self)
|
||||
row = QHBoxLayout(wrap)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
lang_combo = QComboBox(wrap)
|
||||
country_combo = QComboBox(wrap)
|
||||
for lang in QLocale.Language:
|
||||
try:
|
||||
lang_int = self._enum_int(lang)
|
||||
except Exception:
|
||||
continue
|
||||
if lang_int < 0:
|
||||
continue
|
||||
name_txt = QLocale.languageToString(QLocale.Language(lang_int))
|
||||
lang_combo.addItem(name_txt, lang_int)
|
||||
|
||||
def populate_countries():
|
||||
country_combo.blockSignals(True)
|
||||
country_combo.clear()
|
||||
for terr in QLocale.Country:
|
||||
try:
|
||||
terr_int = self._enum_int(terr)
|
||||
except Exception:
|
||||
continue
|
||||
if terr_int < 0:
|
||||
continue
|
||||
text = QLocale.countryToString(QLocale.Country(terr_int))
|
||||
country_combo.addItem(text, terr_int)
|
||||
cur_country = self._enum_int(loc.country())
|
||||
idx = country_combo.findData(cur_country)
|
||||
if idx >= 0:
|
||||
country_combo.setCurrentIndex(idx)
|
||||
country_combo.blockSignals(False)
|
||||
|
||||
cur_lang = self._enum_int(loc.language())
|
||||
idx = lang_combo.findData(cur_lang)
|
||||
if idx >= 0:
|
||||
lang_combo.setCurrentIndex(idx)
|
||||
populate_countries()
|
||||
|
||||
def apply_locale():
|
||||
lang = QLocale.Language(int(lang_combo.currentData()))
|
||||
country = QLocale.Country(int(country_combo.currentData()))
|
||||
self._target.setProperty(name, QLocale(lang, country))
|
||||
|
||||
lang_combo.currentIndexChanged.connect(lambda _=None: populate_countries())
|
||||
lang_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
|
||||
country_combo.currentIndexChanged.connect(lambda _=None: apply_locale())
|
||||
row.addWidget(lang_combo)
|
||||
row.addWidget(country_combo)
|
||||
return wrap
|
||||
|
||||
def _make_icon_editor(self, name: str, icon):
|
||||
btn = QPushButton(self)
|
||||
btn.setText("Choose…")
|
||||
if isinstance(icon, QIcon) and not icon.isNull():
|
||||
btn.setIcon(icon)
|
||||
|
||||
def pick():
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Icon", "", "Images (*.png *.jpg *.jpeg *.bmp *.svg)"
|
||||
)
|
||||
if path:
|
||||
ic = QIcon(path)
|
||||
self._target.setProperty(name, ic)
|
||||
btn.setIcon(ic)
|
||||
|
||||
btn.clicked.connect(pick)
|
||||
return btn
|
||||
|
||||
def _spin_pair(self, ints: bool = True):
|
||||
box1 = QSpinBox(self) if ints else QDoubleSpinBox(self)
|
||||
box2 = QSpinBox(self) if ints else QDoubleSpinBox(self)
|
||||
if ints:
|
||||
box1.setRange(-10_000_000, 10_000_000)
|
||||
box2.setRange(-10_000_000, 10_000_000)
|
||||
else:
|
||||
for b in (box1, box2):
|
||||
b.setDecimals(6)
|
||||
b.setRange(-1e12, 1e12)
|
||||
b.setSingleStep(0.1)
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
wrap = QWidget(self)
|
||||
wrap.setLayout(row)
|
||||
row.addWidget(box1)
|
||||
row.addWidget(box2)
|
||||
return wrap, box1, box2
|
||||
|
||||
def _spin_quad(self, ints: bool = True):
|
||||
s = QSpinBox if ints else QDoubleSpinBox
|
||||
boxes = [s(self) for _ in range(4)]
|
||||
if ints:
|
||||
for b in boxes:
|
||||
b.setRange(-10_000_000, 10_000_000)
|
||||
else:
|
||||
for b in boxes:
|
||||
b.setDecimals(6)
|
||||
b.setRange(-1e12, 1e12)
|
||||
b.setSingleStep(0.1)
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
wrap = QWidget(self)
|
||||
wrap.setLayout(row)
|
||||
for b in boxes:
|
||||
row.addWidget(b)
|
||||
return wrap, boxes
|
||||
|
||||
def _make_font_editor(self, name: str, value):
|
||||
btn = QPushButton(self)
|
||||
if isinstance(value, QFont):
|
||||
btn.setText(f"{value.family()}, {value.pointSize()}pt")
|
||||
else:
|
||||
btn.setText("Select font…")
|
||||
|
||||
def pick():
|
||||
ok, font = QFontDialog.getFont(
|
||||
value if isinstance(value, QFont) else QFont(), self, "Select Font"
|
||||
)
|
||||
if ok:
|
||||
self._target.setProperty(name, font)
|
||||
btn.setText(f"{font.family()}, {font.pointSize()}pt")
|
||||
|
||||
btn.clicked.connect(pick)
|
||||
return btn
|
||||
|
||||
def _make_color_editor(self, initial: QColor, apply_cb):
|
||||
btn = QPushButton(self)
|
||||
if isinstance(initial, QColor):
|
||||
btn.setText(initial.name())
|
||||
btn.setStyleSheet(f"background:{initial.name()};")
|
||||
else:
|
||||
btn.setText("Select color…")
|
||||
|
||||
def pick():
|
||||
col = QColorDialog.getColor(
|
||||
initial if isinstance(initial, QColor) else QColor(), self, "Select Color"
|
||||
)
|
||||
if col.isValid():
|
||||
apply_cb(col)
|
||||
btn.setText(col.name())
|
||||
btn.setStyleSheet(f"background:{col.name()};")
|
||||
|
||||
btn.clicked.connect(pick)
|
||||
return btn
|
||||
|
||||
def _apply_palette_color(
|
||||
self,
|
||||
name: str,
|
||||
pal: QPalette,
|
||||
group: QPalette.ColorGroup,
|
||||
role: QPalette.ColorRole,
|
||||
col: QColor,
|
||||
):
|
||||
pal.setColor(group, role, col)
|
||||
self._target.setProperty(name, pal)
|
||||
|
||||
def _make_palette_editor(self, name: str, pal: QPalette):
|
||||
if not isinstance(pal, QPalette):
|
||||
return None
|
||||
wrap = QWidget(self)
|
||||
row = QHBoxLayout(wrap)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
group_combo = QComboBox(wrap)
|
||||
role_combo = QComboBox(wrap)
|
||||
pick_btn = self._make_color_editor(
|
||||
pal.color(QPalette.Active, QPalette.WindowText),
|
||||
lambda col: self._apply_palette_color(
|
||||
name, pal, QPalette.Active, QPalette.WindowText, col
|
||||
),
|
||||
)
|
||||
groups = [
|
||||
(QPalette.Active, "Active"),
|
||||
(QPalette.Inactive, "Inactive"),
|
||||
(QPalette.Disabled, "Disabled"),
|
||||
]
|
||||
for g, label in groups:
|
||||
group_combo.addItem(label, int(getattr(g, "value", g)))
|
||||
roles = [
|
||||
(QPalette.WindowText, "WindowText"),
|
||||
(QPalette.Window, "Window"),
|
||||
(QPalette.Base, "Base"),
|
||||
(QPalette.AlternateBase, "AlternateBase"),
|
||||
(QPalette.ToolTipBase, "ToolTipBase"),
|
||||
(QPalette.ToolTipText, "ToolTipText"),
|
||||
(QPalette.Text, "Text"),
|
||||
(QPalette.Button, "Button"),
|
||||
(QPalette.ButtonText, "ButtonText"),
|
||||
(QPalette.BrightText, "BrightText"),
|
||||
(QPalette.Highlight, "Highlight"),
|
||||
(QPalette.HighlightedText, "HighlightedText"),
|
||||
]
|
||||
for r, label in roles:
|
||||
role_combo.addItem(label, int(getattr(r, "value", r)))
|
||||
|
||||
def rewire_button():
|
||||
g = QPalette.ColorGroup(int(group_combo.currentData()))
|
||||
r = QPalette.ColorRole(int(role_combo.currentData()))
|
||||
col = pal.color(g, r)
|
||||
while row.count() > 2:
|
||||
w = row.takeAt(2).widget()
|
||||
if w:
|
||||
w.deleteLater()
|
||||
btn = self._make_color_editor(
|
||||
col, lambda c: self._apply_palette_color(name, pal, g, r, c)
|
||||
)
|
||||
row.addWidget(btn)
|
||||
|
||||
group_combo.currentIndexChanged.connect(lambda _: rewire_button())
|
||||
role_combo.currentIndexChanged.connect(lambda _: rewire_button())
|
||||
row.addWidget(group_combo)
|
||||
row.addWidget(role_combo)
|
||||
row.addWidget(pick_btn)
|
||||
return wrap
|
||||
|
||||
def _make_cursor_editor(self, name: str, value):
|
||||
combo = QComboBox(self)
|
||||
shapes = [
|
||||
(Qt.ArrowCursor, "Arrow"),
|
||||
(Qt.IBeamCursor, "IBeam"),
|
||||
(Qt.WaitCursor, "Wait"),
|
||||
(Qt.CrossCursor, "Cross"),
|
||||
(Qt.UpArrowCursor, "UpArrow"),
|
||||
(Qt.SizeAllCursor, "SizeAll"),
|
||||
(Qt.PointingHandCursor, "PointingHand"),
|
||||
(Qt.ForbiddenCursor, "Forbidden"),
|
||||
(Qt.WhatsThisCursor, "WhatsThis"),
|
||||
(Qt.BusyCursor, "Busy"),
|
||||
]
|
||||
current_shape = None
|
||||
if isinstance(value, QCursor):
|
||||
try:
|
||||
enum_val = value.shape()
|
||||
current_shape = int(getattr(enum_val, "value", enum_val))
|
||||
except Exception:
|
||||
current_shape = None
|
||||
for shape, text in shapes:
|
||||
combo.addItem(text, int(getattr(shape, "value", shape)))
|
||||
if current_shape is not None:
|
||||
idx = combo.findData(current_shape)
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
def apply_index(i):
|
||||
shape_val = int(combo.itemData(i))
|
||||
self._target.setProperty(name, QCursor(Qt.CursorShape(shape_val)))
|
||||
|
||||
combo.currentIndexChanged.connect(apply_index)
|
||||
return combo
|
||||
|
||||
def _add_property_row(self, parent: QTreeWidgetItem, name: str, value, prop):
|
||||
item = QTreeWidgetItem(parent, [name, ""])
|
||||
editor = self._make_editor(name, value, prop)
|
||||
if editor is not None:
|
||||
self.tree.setItemWidget(item, 1, editor)
|
||||
else:
|
||||
item.setText(1, repr(value))
|
||||
|
||||
def _is_bec_metaobject(self, mo) -> bool:
|
||||
cname = mo.className()
|
||||
for cls in type(self._target).mro():
|
||||
if getattr(cls, "__name__", None) == cname:
|
||||
mod = getattr(cls, "__module__", "")
|
||||
return mod.startswith("bec_widgets")
|
||||
return False
|
||||
|
||||
def _enum_text(self, meta_enum: QMetaEnum, value_int: int) -> str:
|
||||
if not meta_enum.isFlag():
|
||||
key = meta_enum.valueToKey(value_int)
|
||||
return key.decode() if isinstance(key, (bytes, bytearray)) else (key or str(value_int))
|
||||
parts = []
|
||||
for i in range(meta_enum.keyCount()):
|
||||
k = meta_enum.key(i)
|
||||
v = meta_enum.value(i)
|
||||
if value_int & v:
|
||||
k = k.decode() if isinstance(k, (bytes, bytearray)) else k
|
||||
parts.append(k)
|
||||
return " | ".join(parts) if parts else "0"
|
||||
|
||||
def _enum_value_to_int(self, meta_enum: QMetaEnum, value) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
pass
|
||||
v = getattr(value, "value", None)
|
||||
if isinstance(v, (int,)):
|
||||
return int(v)
|
||||
n = getattr(value, "name", None)
|
||||
if isinstance(n, str):
|
||||
res = meta_enum.keyToValue(n)
|
||||
if res != -1:
|
||||
return int(res)
|
||||
s = str(value)
|
||||
parts = [p.strip() for p in s.replace(",", "|").split("|")]
|
||||
keys = []
|
||||
for p in parts:
|
||||
if "." in p:
|
||||
p = p.split(".")[-1]
|
||||
keys.append(p)
|
||||
keystr = "|".join(keys)
|
||||
try:
|
||||
res = meta_enum.keysToValue(keystr)
|
||||
if res != -1:
|
||||
return int(res)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def _make_enum_editor(self, name: str, value, prop):
|
||||
meta_enum = prop.enumerator()
|
||||
current = self._enum_value_to_int(meta_enum, value)
|
||||
|
||||
if not meta_enum.isFlag():
|
||||
combo = QComboBox(self)
|
||||
for i in range(meta_enum.keyCount()):
|
||||
key = meta_enum.key(i)
|
||||
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
|
||||
combo.addItem(key, meta_enum.value(i))
|
||||
idx = combo.findData(current)
|
||||
if idx < 0:
|
||||
txt = self._enum_text(meta_enum, current)
|
||||
idx = combo.findText(txt)
|
||||
combo.setCurrentIndex(max(idx, 0))
|
||||
|
||||
def apply_index(i):
|
||||
v = combo.itemData(i)
|
||||
self._target.setProperty(name, int(v))
|
||||
|
||||
combo.currentIndexChanged.connect(apply_index)
|
||||
return combo
|
||||
|
||||
btn = QToolButton(self)
|
||||
btn.setText(self._enum_text(meta_enum, current))
|
||||
btn.setPopupMode(QToolButton.InstantPopup)
|
||||
menu = QMenu(btn)
|
||||
actions = []
|
||||
for i in range(meta_enum.keyCount()):
|
||||
key = meta_enum.key(i)
|
||||
key = key.decode() if isinstance(key, (bytes, bytearray)) else key
|
||||
act = menu.addAction(key)
|
||||
act.setCheckable(True)
|
||||
act.setChecked(bool(current & meta_enum.value(i)))
|
||||
actions.append(act)
|
||||
btn.setMenu(menu)
|
||||
|
||||
def apply_flags():
|
||||
flags = 0
|
||||
for i, act in enumerate(actions):
|
||||
if act.isChecked():
|
||||
flags |= meta_enum.value(i)
|
||||
self._target.setProperty(name, int(flags))
|
||||
btn.setText(self._enum_text(meta_enum, flags))
|
||||
|
||||
menu.triggered.connect(lambda _a: apply_flags())
|
||||
return btn
|
||||
|
||||
def _make_editor(self, name: str, value, prop):
|
||||
from qtpy.QtCore import QPoint, QPointF, QRect, QRectF, QSize, QSizeF
|
||||
|
||||
if prop.isEnumType():
|
||||
return self._make_enum_editor(name, value, prop)
|
||||
if isinstance(value, QColor):
|
||||
return self._make_color_editor(value, lambda col: self._target.setProperty(name, col))
|
||||
if isinstance(value, QFont):
|
||||
return self._make_font_editor(name, value)
|
||||
if isinstance(value, QPalette):
|
||||
return self._make_palette_editor(name, value)
|
||||
if isinstance(value, QCursor):
|
||||
return self._make_cursor_editor(name, value)
|
||||
if isinstance(value, QSizePolicy):
|
||||
ed = self._make_sizepolicy_editor(name, value)
|
||||
if ed is not None:
|
||||
return ed
|
||||
if isinstance(value, QLocale):
|
||||
ed = self._make_locale_editor(name, value)
|
||||
if ed is not None:
|
||||
return ed
|
||||
if isinstance(value, QIcon):
|
||||
ed = self._make_icon_editor(name, value)
|
||||
if ed is not None:
|
||||
return ed
|
||||
if isinstance(value, QSize):
|
||||
wrap, w, h = self._spin_pair(ints=True)
|
||||
w.setValue(value.width())
|
||||
h.setValue(value.height())
|
||||
w.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
|
||||
)
|
||||
h.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSize(w.value(), h.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QSizeF):
|
||||
wrap, w, h = self._spin_pair(ints=False)
|
||||
w.setValue(value.width())
|
||||
h.setValue(value.height())
|
||||
w.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
|
||||
)
|
||||
h.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QSizeF(w.value(), h.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QPoint):
|
||||
wrap, x, y = self._spin_pair(ints=True)
|
||||
x.setValue(value.x())
|
||||
y.setValue(value.y())
|
||||
x.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
|
||||
)
|
||||
y.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPoint(x.value(), y.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QPointF):
|
||||
wrap, x, y = self._spin_pair(ints=False)
|
||||
x.setValue(value.x())
|
||||
y.setValue(value.y())
|
||||
x.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
|
||||
)
|
||||
y.valueChanged.connect(
|
||||
lambda _: self._target.setProperty(name, QPointF(x.value(), y.value()))
|
||||
)
|
||||
return wrap
|
||||
if isinstance(value, QRect):
|
||||
wrap, boxes = self._spin_quad(ints=True)
|
||||
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
|
||||
b.setValue(v)
|
||||
|
||||
def apply_rect():
|
||||
self._target.setProperty(
|
||||
name,
|
||||
QRect(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
|
||||
)
|
||||
|
||||
for b in boxes:
|
||||
b.valueChanged.connect(lambda _=None: apply_rect())
|
||||
return wrap
|
||||
if isinstance(value, QRectF):
|
||||
wrap, boxes = self._spin_quad(ints=False)
|
||||
for b, v in zip(boxes, (value.x(), value.y(), value.width(), value.height())):
|
||||
b.setValue(v)
|
||||
|
||||
def apply_rectf():
|
||||
self._target.setProperty(
|
||||
name,
|
||||
QRectF(boxes[0].value(), boxes[1].value(), boxes[2].value(), boxes[3].value()),
|
||||
)
|
||||
|
||||
for b in boxes:
|
||||
b.valueChanged.connect(lambda _=None: apply_rectf())
|
||||
return wrap
|
||||
if isinstance(value, bool):
|
||||
w = QCheckBox(self)
|
||||
w.setChecked(bool(value))
|
||||
w.toggled.connect(lambda v: self._target.setProperty(name, v))
|
||||
return w
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
w = QSpinBox(self)
|
||||
w.setRange(-10_000_000, 10_000_000)
|
||||
w.setValue(int(value))
|
||||
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
|
||||
return w
|
||||
if isinstance(value, float):
|
||||
w = QDoubleSpinBox(self)
|
||||
w.setDecimals(6)
|
||||
w.setRange(-1e12, 1e12)
|
||||
w.setSingleStep(0.1)
|
||||
w.setValue(float(value))
|
||||
w.valueChanged.connect(lambda v: self._target.setProperty(name, v))
|
||||
return w
|
||||
if isinstance(value, str):
|
||||
w = QLineEdit(self)
|
||||
w.setText(value)
|
||||
w.editingFinished.connect(lambda: self._target.setProperty(name, w.text()))
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
class DemoApp(QWidget): # pragma: no cover:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
|
||||
# Create a BECWidget instance example
|
||||
waveform = self.create_waveform()
|
||||
|
||||
# property editor for the BECWidget
|
||||
property_editor = PropertyEditor(waveform, show_only_bec=True)
|
||||
|
||||
layout.addWidget(waveform)
|
||||
layout.addWidget(property_editor)
|
||||
|
||||
def create_waveform(self):
|
||||
"""Create a new waveform widget."""
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
waveform = Waveform(parent=self)
|
||||
waveform.title = "New Waveform"
|
||||
waveform.x_label = "X Axis"
|
||||
waveform.y_label = "Y Axis"
|
||||
return waveform
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover:
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
demo = DemoApp()
|
||||
demo.setWindowTitle("Property Editor Demo")
|
||||
demo.resize(1200, 800)
|
||||
demo.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -465,13 +465,19 @@ class WidgetHierarchy:
|
||||
"""
|
||||
from bec_widgets.utils import BECConnector
|
||||
|
||||
# Guard against deleted/invalid Qt wrappers
|
||||
if not shb.isValid(widget):
|
||||
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:
|
||||
if not shb.isValid(parent):
|
||||
return None
|
||||
if isinstance(parent, BECConnector):
|
||||
return parent
|
||||
parent = parent.parent()
|
||||
parent = parent.parent() if hasattr(parent, "parent") else None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -553,6 +559,64 @@ class WidgetHierarchy:
|
||||
WidgetIO.set_value(child, value)
|
||||
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
|
||||
def hierarchy_example(): # pragma: no cover
|
||||
|
||||
@@ -15,6 +15,8 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
@@ -29,43 +31,58 @@ class WidgetStateManager:
|
||||
def __init__(self, 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.
|
||||
|
||||
Args:
|
||||
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(
|
||||
self.widget, "Save Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
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.
|
||||
|
||||
Args:
|
||||
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(
|
||||
self.widget, "Load Settings", "", "INI Files (*.ini)"
|
||||
)
|
||||
if filename:
|
||||
settings = QSettings(filename, QSettings.IniFormat)
|
||||
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.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to save the state for.
|
||||
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:
|
||||
return
|
||||
@@ -88,21 +105,32 @@ class WidgetStateManager:
|
||||
settings.endGroup()
|
||||
|
||||
# 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 (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
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.
|
||||
|
||||
Args:
|
||||
widget(QWidget): The widget to load the state for.
|
||||
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:
|
||||
return
|
||||
@@ -118,14 +146,21 @@ class WidgetStateManager:
|
||||
widget.setProperty(name, value)
|
||||
settings.endGroup()
|
||||
|
||||
if not recursive:
|
||||
return
|
||||
# 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 (
|
||||
child.objectName()
|
||||
and child.property("skip_settings") is not True
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,909 @@
|
||||
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)
|
||||
|
||||
# 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)
|
||||
0
bec_widgets/widgets/containers/explorer/__init__.py
Normal file
0
bec_widgets/widgets/containers/explorer/__init__.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMimeData, Qt, Signal
|
||||
from qtpy.QtGui import QDrag
|
||||
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
|
||||
|
||||
class CollapsibleSection(QWidget):
|
||||
"""A widget that combines a header button with any content widget for collapsible sections
|
||||
|
||||
This widget contains a header button with a title and a content widget.
|
||||
The content widget can be any QWidget. The header button can be expanded or collapsed.
|
||||
The header also contains an "Add" button that is only visible when hovering over the section.
|
||||
|
||||
Signals:
|
||||
section_reorder_requested(str, str): Emitted when the section is dragged and dropped
|
||||
onto another section for reordering.
|
||||
Arguments are (source_title, target_title).
|
||||
"""
|
||||
|
||||
section_reorder_requested = Signal(str, str) # (source_title, target_title)
|
||||
|
||||
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
|
||||
super().__init__(parent=parent)
|
||||
self.title = title
|
||||
self.content_widget = None
|
||||
self.setAcceptDrops(True)
|
||||
self._expanded = True
|
||||
|
||||
# Setup layout
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(indentation, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.setContentsMargins(0, 0, 4, 0)
|
||||
header_layout.setSpacing(0)
|
||||
|
||||
# Create header button
|
||||
self.header_button = QPushButton()
|
||||
self.header_button.clicked.connect(self.toggle_expanded)
|
||||
|
||||
# Enable drag and drop for reordering
|
||||
self.header_button.setAcceptDrops(True)
|
||||
self.header_button.mousePressEvent = self._header_mouse_press_event
|
||||
self.header_button.mouseMoveEvent = self._header_mouse_move_event
|
||||
self.header_button.dragEnterEvent = self._header_drag_enter_event
|
||||
self.header_button.dropEvent = self._header_drop_event
|
||||
|
||||
self.drag_start_position = None
|
||||
|
||||
# Add header to layout
|
||||
header_layout.addWidget(self.header_button)
|
||||
header_layout.addStretch()
|
||||
|
||||
self.header_add_button = QPushButton()
|
||||
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.header_add_button.setFixedSize(20, 20)
|
||||
self.header_add_button.setToolTip("Add item")
|
||||
self.header_add_button.setVisible(show_add_button)
|
||||
|
||||
self.header_add_button.setIcon(material_icon("add", size=(20, 20)))
|
||||
header_layout.addWidget(self.header_add_button)
|
||||
|
||||
self.main_layout.addLayout(header_layout)
|
||||
|
||||
self._update_expanded_state()
|
||||
|
||||
def set_widget(self, widget):
|
||||
"""Set the content widget for this collapsible section"""
|
||||
# Remove existing content widget if any
|
||||
if self.content_widget and self.content_widget.parent() == self:
|
||||
self.main_layout.removeWidget(self.content_widget)
|
||||
self.content_widget.close()
|
||||
self.content_widget.deleteLater()
|
||||
|
||||
self.content_widget = widget
|
||||
if self.content_widget:
|
||||
self.main_layout.addWidget(self.content_widget)
|
||||
|
||||
self._update_expanded_state()
|
||||
|
||||
def _update_appearance(self):
|
||||
"""Update the header button appearance based on expanded state"""
|
||||
# Use material icons with consistent sizing to match tree items
|
||||
icon_name = "keyboard_arrow_down" if self.expanded else "keyboard_arrow_right"
|
||||
icon = material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=False)
|
||||
|
||||
self.header_button.setIcon(icon)
|
||||
self.header_button.setText(self.title)
|
||||
|
||||
# Get theme colors
|
||||
palette = get_theme_palette()
|
||||
text_color = palette.text().color().name()
|
||||
|
||||
self.header_button.setStyleSheet(
|
||||
f"""
|
||||
QPushButton {{
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: {text_color};
|
||||
icon-size: 20px 20px;
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
def toggle_expanded(self):
|
||||
"""Toggle the expanded state and update size policy"""
|
||||
self.expanded = not self.expanded
|
||||
self._update_expanded_state()
|
||||
|
||||
def _update_expanded_state(self):
|
||||
"""Update the expanded state based on current state"""
|
||||
self._update_appearance()
|
||||
if self.expanded:
|
||||
if self.content_widget:
|
||||
self.content_widget.show()
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
else:
|
||||
if self.content_widget:
|
||||
self.content_widget.hide()
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def expanded(self) -> bool:
|
||||
"""Get the expanded state"""
|
||||
return self._expanded
|
||||
|
||||
@expanded.setter
|
||||
def expanded(self, value: bool):
|
||||
"""Set the expanded state programmatically"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError("Expanded state must be a boolean")
|
||||
if self._expanded == value:
|
||||
return
|
||||
self._expanded = value
|
||||
self._update_appearance()
|
||||
|
||||
def connect_add_button(self, slot):
|
||||
"""Connect a slot to the add button's clicked signal.
|
||||
|
||||
Args:
|
||||
slot: The function to call when the add button is clicked.
|
||||
"""
|
||||
self.header_add_button.clicked.connect(slot)
|
||||
|
||||
def _header_mouse_press_event(self, event):
|
||||
"""Handle mouse press on header for drag start"""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.drag_start_position = event.pos()
|
||||
QPushButton.mousePressEvent(self.header_button, event)
|
||||
|
||||
def _header_mouse_move_event(self, event):
|
||||
"""Handle mouse move to start drag operation"""
|
||||
if event.buttons() & Qt.MouseButton.LeftButton and self.drag_start_position is not None:
|
||||
|
||||
# Check if we've moved far enough to start a drag
|
||||
if (event.pos() - self.drag_start_position).manhattanLength() >= 10:
|
||||
|
||||
self._start_drag()
|
||||
QPushButton.mouseMoveEvent(self.header_button, event)
|
||||
|
||||
def _start_drag(self):
|
||||
"""Start the drag operation with a properly aligned widget pixmap"""
|
||||
drag = QDrag(self.header_button)
|
||||
mime_data = QMimeData()
|
||||
mime_data.setText(f"section:{self.title}")
|
||||
drag.setMimeData(mime_data)
|
||||
|
||||
# Grab a pixmap of the widget
|
||||
widget_pixmap = self.header_button.grab()
|
||||
|
||||
drag.setPixmap(widget_pixmap)
|
||||
|
||||
# Set the hotspot to where the mouse was pressed on the widget
|
||||
drag.setHotSpot(self.drag_start_position)
|
||||
|
||||
drag.exec_(Qt.MoveAction)
|
||||
|
||||
def _header_drag_enter_event(self, event):
|
||||
"""Handle drag enter on header"""
|
||||
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def _header_drop_event(self, event):
|
||||
"""Handle drop on header"""
|
||||
if event.mimeData().hasText() and event.mimeData().text().startswith("section:"):
|
||||
source_title = event.mimeData().text().replace("section:", "")
|
||||
if source_title != self.title:
|
||||
# Emit signal to parent to handle reordering
|
||||
self.section_reorder_requested.emit(source_title, self.title)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
179
bec_widgets/widgets/containers/explorer/explorer.py
Normal file
179
bec_widgets/widgets/containers/explorer/explorer.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QSizePolicy, QSpacerItem, QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
|
||||
|
||||
|
||||
class Explorer(BECWidget, QWidget):
|
||||
"""
|
||||
A widget that combines multiple collapsible sections for an explorer-like interface.
|
||||
Each section can be expanded or collapsed, and sections can be reordered. The explorer
|
||||
can contain also sub-explorers for nested structures.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Main layout
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.main_layout.setSpacing(0)
|
||||
|
||||
# Splitter for sections
|
||||
self.splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
self.main_layout.addWidget(self.splitter)
|
||||
|
||||
# Spacer for when all sections are collapsed
|
||||
self.expander = QSpacerItem(0, 0)
|
||||
self.main_layout.addItem(self.expander)
|
||||
|
||||
# Registry of sections
|
||||
self.sections: list[CollapsibleSection] = []
|
||||
|
||||
# Setup splitter styling
|
||||
self._setup_splitter_styling()
|
||||
|
||||
def add_section(self, section: CollapsibleSection) -> None:
|
||||
"""
|
||||
Add a collapsible section to the explorer
|
||||
|
||||
Args:
|
||||
section (CollapsibleSection): The section to add
|
||||
"""
|
||||
if not isinstance(section, CollapsibleSection):
|
||||
raise TypeError("section must be an instance of CollapsibleSection")
|
||||
|
||||
if section in self.sections:
|
||||
return
|
||||
|
||||
self.sections.append(section)
|
||||
self.splitter.addWidget(section)
|
||||
|
||||
# Connect the section's toggle to update spacer
|
||||
section.header_button.clicked.connect(self._update_spacer)
|
||||
|
||||
# Connect section reordering if supported
|
||||
if hasattr(section, "section_reorder_requested"):
|
||||
section.section_reorder_requested.connect(self._handle_section_reorder)
|
||||
|
||||
self._update_spacer()
|
||||
|
||||
def remove_section(self, section: CollapsibleSection) -> None:
|
||||
"""
|
||||
Remove a collapsible section from the explorer
|
||||
|
||||
Args:
|
||||
section (CollapsibleSection): The section to remove
|
||||
"""
|
||||
if section not in self.sections:
|
||||
return
|
||||
self.sections.remove(section)
|
||||
section.deleteLater()
|
||||
section.close()
|
||||
|
||||
# Disconnect signals
|
||||
try:
|
||||
section.header_button.clicked.disconnect(self._update_spacer)
|
||||
if hasattr(section, "section_reorder_requested"):
|
||||
section.section_reorder_requested.disconnect(self._handle_section_reorder)
|
||||
except RuntimeError:
|
||||
# Signals already disconnected
|
||||
pass
|
||||
|
||||
self._update_spacer()
|
||||
|
||||
def get_section(self, title: str) -> CollapsibleSection | None:
|
||||
"""Get a section by its title"""
|
||||
for section in self.sections:
|
||||
if section.title == title:
|
||||
return section
|
||||
return None
|
||||
|
||||
def _setup_splitter_styling(self) -> None:
|
||||
"""Setup the splitter styling with theme colors"""
|
||||
palette = get_theme_palette()
|
||||
separator_color = palette.mid().color()
|
||||
|
||||
self.splitter.setStyleSheet(
|
||||
f"""
|
||||
QSplitter::handle {{
|
||||
height: 0.1px;
|
||||
background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60);
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
def _update_spacer(self) -> None:
|
||||
"""Update the spacer size based on section states"""
|
||||
any_expanded = any(section.expanded for section in self.sections)
|
||||
|
||||
if any_expanded:
|
||||
self.expander.changeSize(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
else:
|
||||
self.expander.changeSize(
|
||||
0, 10, QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
|
||||
def _handle_section_reorder(self, source_title: str, target_title: str) -> None:
|
||||
"""Handle reordering of sections"""
|
||||
if source_title == target_title:
|
||||
return
|
||||
|
||||
source_section = self.get_section(source_title)
|
||||
target_section = self.get_section(target_title)
|
||||
|
||||
if not source_section or not target_section:
|
||||
return
|
||||
|
||||
# Get current indices
|
||||
source_index = self.splitter.indexOf(source_section)
|
||||
target_index = self.splitter.indexOf(target_section)
|
||||
|
||||
if source_index == -1 or target_index == -1:
|
||||
return
|
||||
|
||||
# Insert at target position
|
||||
self.splitter.insertWidget(target_index, source_section)
|
||||
|
||||
# Update sections
|
||||
self.sections.remove(source_section)
|
||||
self.sections.insert(target_index, source_section)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLabel
|
||||
|
||||
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
|
||||
|
||||
app = QApplication([])
|
||||
explorer = Explorer()
|
||||
section = CollapsibleSection(title="SCRIPTS", indentation=0)
|
||||
|
||||
script_explorer = Explorer()
|
||||
script_widget = ScriptTreeWidget()
|
||||
local_scripts_section = CollapsibleSection(title="Local")
|
||||
local_scripts_section.set_widget(script_widget)
|
||||
script_widget.set_directory(os.path.abspath("./"))
|
||||
script_explorer.add_section(local_scripts_section)
|
||||
|
||||
section.set_widget(script_explorer)
|
||||
explorer.add_section(section)
|
||||
shared_script_section = CollapsibleSection(title="Shared")
|
||||
shared_script_widget = ScriptTreeWidget()
|
||||
shared_script_widget.set_directory(os.path.abspath("./"))
|
||||
shared_script_section.set_widget(shared_script_widget)
|
||||
script_explorer.add_section(shared_script_section)
|
||||
macros_section = CollapsibleSection(title="MACROS", indentation=0)
|
||||
macros_section.set_widget(QLabel("Macros will be implemented later"))
|
||||
explorer.add_section(macros_section)
|
||||
explorer.show()
|
||||
app.exec()
|
||||
387
bec_widgets/widgets/containers/explorer/script_tree_widget.py
Normal file
387
bec_widgets/widgets/containers/explorer/script_tree_widget.py
Normal file
@@ -0,0 +1,387 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
|
||||
from qtpy.QtGui import QAction, QPainter
|
||||
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class FileItemDelegate(QStyledItemDelegate):
|
||||
"""Custom delegate to show action buttons on hover"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.hovered_index = QModelIndex()
|
||||
self.file_actions: list[QAction] = []
|
||||
self.dir_actions: list[QAction] = []
|
||||
self.button_rects: list[QRect] = []
|
||||
self.current_file_path = ""
|
||||
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for files"""
|
||||
self.file_actions.append(action)
|
||||
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directories"""
|
||||
self.dir_actions.append(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions"""
|
||||
self.file_actions.clear()
|
||||
self.dir_actions.clear()
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
"""Paint the item with action buttons on hover"""
|
||||
# Paint the default item
|
||||
super().paint(painter, option, index)
|
||||
|
||||
# Early return if not hovering over this item
|
||||
if index != self.hovered_index:
|
||||
return
|
||||
|
||||
tree_view = self.parent()
|
||||
if not isinstance(tree_view, QTreeView):
|
||||
return
|
||||
|
||||
proxy_model = tree_view.model()
|
||||
if not isinstance(proxy_model, QSortFilterProxyModel):
|
||||
return
|
||||
|
||||
source_index = proxy_model.mapToSource(index)
|
||||
source_model = proxy_model.sourceModel()
|
||||
if not isinstance(source_model, QFileSystemModel):
|
||||
return
|
||||
|
||||
is_dir = source_model.isDir(source_index)
|
||||
file_path = source_model.filePath(source_index)
|
||||
self.current_file_path = file_path
|
||||
|
||||
# Choose appropriate actions based on item type
|
||||
actions = self.dir_actions if is_dir else self.file_actions
|
||||
if actions:
|
||||
self._draw_action_buttons(painter, option, actions)
|
||||
|
||||
def _draw_action_buttons(self, painter, option, actions: list[QAction]):
|
||||
"""Draw action buttons on the right side"""
|
||||
button_size = 18
|
||||
margin = 4
|
||||
spacing = 2
|
||||
|
||||
# Calculate total width needed for all buttons
|
||||
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
|
||||
|
||||
# Clear previous button rects and create new ones
|
||||
self.button_rects.clear()
|
||||
|
||||
# Calculate starting position (right side of the item)
|
||||
start_x = option.rect.right() - total_width - margin
|
||||
current_x = start_x
|
||||
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Get theme colors for better integration
|
||||
palette = get_theme_palette()
|
||||
button_bg = palette.button().color()
|
||||
button_bg.setAlpha(150) # Semi-transparent
|
||||
|
||||
for action in actions:
|
||||
if not action.isVisible():
|
||||
continue
|
||||
|
||||
# Calculate button position
|
||||
button_rect = QRect(
|
||||
current_x,
|
||||
option.rect.top() + (option.rect.height() - button_size) // 2,
|
||||
button_size,
|
||||
button_size,
|
||||
)
|
||||
self.button_rects.append(button_rect)
|
||||
|
||||
# Draw button background
|
||||
painter.setBrush(button_bg)
|
||||
painter.setPen(palette.mid().color())
|
||||
painter.drawRoundedRect(button_rect, 3, 3)
|
||||
|
||||
# Draw action icon
|
||||
icon = action.icon()
|
||||
if not icon.isNull():
|
||||
icon_rect = button_rect.adjusted(2, 2, -2, -2)
|
||||
icon.paint(painter, icon_rect)
|
||||
|
||||
# Move to next button position
|
||||
current_x += button_size + spacing
|
||||
|
||||
painter.restore()
|
||||
|
||||
def editorEvent(self, event, model, option, index):
|
||||
"""Handle mouse events for action buttons"""
|
||||
# Early return if not a left click
|
||||
if not (
|
||||
event.type() == event.Type.MouseButtonPress
|
||||
and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
# Early return if not a proxy model
|
||||
if not isinstance(model, QSortFilterProxyModel):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
source_index = model.mapToSource(index)
|
||||
source_model = model.sourceModel()
|
||||
|
||||
# Early return if not a file system model
|
||||
if not isinstance(source_model, QFileSystemModel):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
is_dir = source_model.isDir(source_index)
|
||||
actions = self.dir_actions if is_dir else self.file_actions
|
||||
|
||||
# Check which button was clicked
|
||||
visible_actions = [action for action in actions if action.isVisible()]
|
||||
for i, button_rect in enumerate(self.button_rects):
|
||||
if button_rect.contains(event.pos()) and i < len(visible_actions):
|
||||
# Trigger the action
|
||||
visible_actions[i].trigger()
|
||||
return True
|
||||
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
def set_hovered_index(self, index):
|
||||
"""Set the currently hovered index"""
|
||||
self.hovered_index = index
|
||||
|
||||
|
||||
class ScriptTreeWidget(QWidget):
|
||||
"""A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection"""
|
||||
|
||||
file_selected = Signal(str) # Script file path selected
|
||||
file_open_requested = Signal(str) # File open button clicked
|
||||
file_renamed = Signal(str, str) # Old path, new path
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Create layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create tree view
|
||||
self.tree = QTreeView()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setRootIsDecorated(True)
|
||||
|
||||
# Enable mouse tracking for hover effects
|
||||
self.tree.setMouseTracking(True)
|
||||
|
||||
# Create file system model
|
||||
self.model = QFileSystemModel()
|
||||
self.model.setNameFilters(["*.py"])
|
||||
self.model.setNameFilterDisables(False)
|
||||
|
||||
# Create proxy model to filter out underscore directories
|
||||
self.proxy_model = QSortFilterProxyModel()
|
||||
self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*"))
|
||||
self.proxy_model.setSourceModel(self.model)
|
||||
self.tree.setModel(self.proxy_model)
|
||||
|
||||
# Create and set custom delegate
|
||||
self.delegate = FileItemDelegate(self.tree)
|
||||
self.tree.setItemDelegate(self.delegate)
|
||||
|
||||
# Add default open button for files
|
||||
action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self)
|
||||
action.action.triggered.connect(self._on_file_open_requested)
|
||||
self.delegate.add_file_action(action.action)
|
||||
|
||||
# Remove unnecessary columns
|
||||
self.tree.setColumnHidden(1, True) # Hide size column
|
||||
self.tree.setColumnHidden(2, True) # Hide type column
|
||||
self.tree.setColumnHidden(3, True) # Hide date modified column
|
||||
|
||||
# Apply BEC styling
|
||||
self._apply_styling()
|
||||
|
||||
# Script specific properties
|
||||
self.directory = None
|
||||
|
||||
# Connect signals
|
||||
self.tree.clicked.connect(self._on_item_clicked)
|
||||
self.tree.doubleClicked.connect(self._on_item_double_clicked)
|
||||
|
||||
# Install event filter for hover tracking
|
||||
self.tree.viewport().installEventFilter(self)
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(self.tree)
|
||||
|
||||
def _apply_styling(self):
|
||||
"""Apply styling to the tree widget"""
|
||||
# Get theme colors for subtle tree lines
|
||||
palette = get_theme_palette()
|
||||
subtle_line_color = palette.mid().color()
|
||||
subtle_line_color.setAlpha(80)
|
||||
|
||||
# pylint: disable=f-string-without-interpolation
|
||||
tree_style = f"""
|
||||
QTreeView {{
|
||||
border: none;
|
||||
outline: 0;
|
||||
show-decoration-selected: 0;
|
||||
}}
|
||||
QTreeView::branch {{
|
||||
border-image: none;
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
QTreeView::item {{
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}}
|
||||
QTreeView::item:hover {{
|
||||
background: palette(midlight);
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
text-decoration: none;
|
||||
}}
|
||||
QTreeView::item:selected {{
|
||||
background: palette(highlight);
|
||||
color: palette(highlighted-text);
|
||||
}}
|
||||
QTreeView::item:selected:hover {{
|
||||
background: palette(highlight);
|
||||
}}
|
||||
"""
|
||||
|
||||
self.tree.setStyleSheet(tree_style)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle mouse move events for hover tracking"""
|
||||
# Early return if not the tree viewport
|
||||
if obj != self.tree.viewport():
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.MouseMove:
|
||||
index = self.tree.indexAt(event.pos())
|
||||
if index.isValid():
|
||||
self.delegate.set_hovered_index(index)
|
||||
else:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.Leave:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def set_directory(self, directory):
|
||||
"""Set the scripts directory"""
|
||||
self.directory = directory
|
||||
|
||||
# Early return if directory doesn't exist
|
||||
if not directory or not os.path.exists(directory):
|
||||
return
|
||||
|
||||
root_index = self.model.setRootPath(directory)
|
||||
# Map the source model index to proxy model index
|
||||
proxy_root_index = self.proxy_model.mapFromSource(root_index)
|
||||
self.tree.setRootIndex(proxy_root_index)
|
||||
self.tree.expandAll()
|
||||
|
||||
def _on_item_clicked(self, index: QModelIndex):
|
||||
"""Handle item clicks"""
|
||||
# Map proxy index back to source index
|
||||
source_index = self.proxy_model.mapToSource(index)
|
||||
|
||||
# Early return for directories
|
||||
if self.model.isDir(source_index):
|
||||
return
|
||||
|
||||
file_path = self.model.filePath(source_index)
|
||||
|
||||
# Early return if not a valid file
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
path_obj = Path(file_path)
|
||||
|
||||
# Only emit signal for Python files
|
||||
if path_obj.suffix.lower() == ".py":
|
||||
logger.info(f"Script selected: {file_path}")
|
||||
self.file_selected.emit(file_path)
|
||||
|
||||
def _on_item_double_clicked(self, index: QModelIndex):
|
||||
"""Handle item double-clicks"""
|
||||
# Map proxy index back to source index
|
||||
source_index = self.proxy_model.mapToSource(index)
|
||||
|
||||
# Early return for directories
|
||||
if self.model.isDir(source_index):
|
||||
return
|
||||
|
||||
file_path = self.model.filePath(source_index)
|
||||
|
||||
# Early return if not a valid file
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
# Emit signal to open the file
|
||||
logger.info(f"File open requested via double-click: {file_path}")
|
||||
self.file_open_requested.emit(file_path)
|
||||
|
||||
def _on_file_open_requested(self):
|
||||
"""Handle file open action triggered"""
|
||||
logger.info("File open requested")
|
||||
# Early return if no hovered item
|
||||
if not self.delegate.hovered_index.isValid():
|
||||
return
|
||||
|
||||
source_index = self.proxy_model.mapToSource(self.delegate.hovered_index)
|
||||
file_path = self.model.filePath(source_index)
|
||||
|
||||
# Early return if not a valid file
|
||||
if not file_path or not os.path.isfile(file_path):
|
||||
return
|
||||
|
||||
self.file_open_requested.emit(file_path)
|
||||
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for file items"""
|
||||
self.delegate.add_file_action(action)
|
||||
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directory items"""
|
||||
self.delegate.add_dir_action(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions from items"""
|
||||
self.delegate.clear_actions()
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the tree view"""
|
||||
if self.directory is None:
|
||||
return
|
||||
self.model.setRootPath("") # Reset
|
||||
root_index = self.model.setRootPath(self.directory)
|
||||
proxy_root_index = self.proxy_model.mapFromSource(root_index)
|
||||
self.tree.setRootIndex(proxy_root_index)
|
||||
|
||||
def expand_all(self):
|
||||
"""Expand all items in the tree"""
|
||||
self.tree.expandAll()
|
||||
|
||||
def collapse_all(self):
|
||||
"""Collapse all items in the tree"""
|
||||
self.tree.collapseAll()
|
||||
@@ -357,7 +357,7 @@ class BECMainWindow(BECWidget, QMainWindow):
|
||||
|
||||
########################################
|
||||
# Theme menu
|
||||
theme_menu = menu_bar.addMenu("Theme")
|
||||
theme_menu = menu_bar.addMenu("View")
|
||||
|
||||
theme_group = QActionGroup(self)
|
||||
light_theme_action = QAction("Light Theme", self, checkable=True)
|
||||
|
||||
@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
|
||||
PLUGIN = True
|
||||
RPC = True
|
||||
|
||||
USER_ACCESS = ["set_positioner", "screenshot"]
|
||||
USER_ACCESS = ["set_positioner", "attach", "detach", "screenshot"]
|
||||
device_changed = Signal(str, str)
|
||||
# Signal emitted to inform listeners about a position update
|
||||
position_update = Signal(float)
|
||||
|
||||
@@ -34,7 +34,7 @@ class PositionerBox2D(PositionerBoxBase):
|
||||
|
||||
PLUGIN = 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_ver = Signal(str, str)
|
||||
|
||||
@@ -62,7 +62,7 @@ class PositionerGroup(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
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
|
||||
position_update = Signal(float)
|
||||
|
||||
@@ -112,7 +112,9 @@ class DeviceInputBase(BECWidget):
|
||||
WidgetIO.set_value(widget=self, value=device)
|
||||
self.config.default = device
|
||||
else:
|
||||
logger.warning(f"Device {device} is not in the filtered selection.")
|
||||
logger.warning(
|
||||
f"Device {device} is not in the filtered selection of {self}: {self.devices}."
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def update_devices_from_filters(self):
|
||||
@@ -131,7 +133,8 @@ class DeviceInputBase(BECWidget):
|
||||
# Filter based on readout priority
|
||||
devs = [dev for dev in devs if self._check_readout_filter(dev)]
|
||||
self.devices = [device.name for device in devs]
|
||||
self.set_device(current_device)
|
||||
if current_device != "":
|
||||
self.set_device(current_device)
|
||||
|
||||
@SafeSlot(list)
|
||||
def set_available_devices(self, devices: list[str]):
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .available_device_resources import AvailableDeviceResources
|
||||
|
||||
__all__ = ["AvailableDeviceResources"]
|
||||
@@ -0,0 +1,84 @@
|
||||
from random import randint
|
||||
from typing import Any, Callable, Generator, Iterable, TypeVar
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QListWidgetItem, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import (
|
||||
Ui_availableDeviceResources,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
|
||||
HashableDevice,
|
||||
get_backend,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_tag_group import (
|
||||
DeviceTagGroup,
|
||||
)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_RT = TypeVar("_RT")
|
||||
|
||||
|
||||
def _yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator[_RT, Any, None]:
|
||||
for v in vals:
|
||||
try:
|
||||
yield fn(v)
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
|
||||
class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources):
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setupUi(self)
|
||||
self._backend = get_backend()
|
||||
self._items: dict[str, tuple[QListWidgetItem, DeviceTagGroup]] = {}
|
||||
self.refresh_full_list()
|
||||
|
||||
def refresh_full_list(self):
|
||||
self.tag_groups_list.clear()
|
||||
self._items = {}
|
||||
for tag_group, devices in self._backend.tag_groups.items():
|
||||
self._add_tag_group(tag_group, devices)
|
||||
self._add_tag_group("Untagged devices", self._backend.untagged_devices)
|
||||
|
||||
def _add_tag_group(self, tag_group: str, devices: set[HashableDevice]):
|
||||
self.tag_groups_list.add_item(
|
||||
tag_group, self.tag_groups_list, tag_group, devices, expanded=False
|
||||
)
|
||||
|
||||
def _reset_devices_state(self):
|
||||
for _, tag_group in self._items.values():
|
||||
tag_group.reset_devices_state()
|
||||
|
||||
def set_devices_state(self, devices: Iterable[HashableDevice], included: bool):
|
||||
for device in devices:
|
||||
for _, tag_group in self._items.values():
|
||||
tag_group.set_item_state(hash(device), included)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
for list_item, tag_group_widget in self._items.values():
|
||||
list_item.setSizeHint(tag_group_widget.sizeHint())
|
||||
|
||||
@SafeSlot(list)
|
||||
def update_devices_state(self, config_list: list[dict[str, Any]]):
|
||||
self.set_devices_state(
|
||||
_yield_only_passing(HashableDevice.model_validate, config_list), True
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = AvailableDeviceResources()
|
||||
widget.set_devices_state(
|
||||
list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True
|
||||
)
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,32 @@
|
||||
from qtpy.QtCore import QMetaObject, Qt
|
||||
from qtpy.QtWidgets import QAbstractItemView, QListView, QListWidget, QVBoxLayout
|
||||
|
||||
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_tag_group import (
|
||||
DeviceTagGroup,
|
||||
)
|
||||
|
||||
|
||||
class Ui_availableDeviceResources(object):
|
||||
def setupUi(self, availableDeviceResources):
|
||||
if not availableDeviceResources.objectName():
|
||||
availableDeviceResources.setObjectName("availableDeviceResources")
|
||||
self.verticalLayout = QVBoxLayout(availableDeviceResources)
|
||||
self.verticalLayout.setObjectName("verticalLayout")
|
||||
self.tag_groups_list = ListOfExpandableFrames(availableDeviceResources, DeviceTagGroup)
|
||||
self.tag_groups_list.setObjectName("tag_groups_list")
|
||||
self.tag_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||||
self.tag_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.tag_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.tag_groups_list.setMovement(QListView.Movement.Static)
|
||||
self.tag_groups_list.setSpacing(2)
|
||||
self.tag_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly)
|
||||
self.tag_groups_list.setDragEnabled(True)
|
||||
self.tag_groups_list.setAcceptDrops(False)
|
||||
self.tag_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
availableDeviceResources.setMinimumWidth(250)
|
||||
availableDeviceResources.resize(250, availableDeviceResources.height())
|
||||
|
||||
self.verticalLayout.addWidget(self.tag_groups_list)
|
||||
|
||||
QMetaObject.connectSlotsByName(availableDeviceResources)
|
||||
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import operator
|
||||
import os
|
||||
from enum import Enum, auto
|
||||
from functools import partial, reduce
|
||||
from glob import glob
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import AbstractSet, Protocol
|
||||
|
||||
import bec_lib
|
||||
from bec_lib.atlas_models import Device
|
||||
from bec_lib.bec_yaml_loader import yaml_load
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from pydantic import model_validator
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
DEVICE_HASH_MODEL_KEY = "_hash_model"
|
||||
_BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.."
|
||||
|
||||
|
||||
class HashModel(str, Enum):
|
||||
DEFAULT = auto()
|
||||
DEFAULT_DEVICECONFIG = auto()
|
||||
DEFAULT_EPICS = auto()
|
||||
|
||||
|
||||
def _hash_input(device: HashableDevice) -> bytes:
|
||||
"""Get the data for the hash for this device as a byte string"""
|
||||
|
||||
def _default(device: HashableDevice):
|
||||
"""By default, we use name and device class"""
|
||||
return (device.name + device.deviceClass).encode()
|
||||
|
||||
def _default_deviceconfig(device: HashableDevice):
|
||||
config_values = sorted(
|
||||
(str(kv) for kv in device.deviceConfig.items()) if device.deviceConfig else []
|
||||
)
|
||||
return (reduce(operator.add, (device.name, device.deviceClass, *config_values))).encode()
|
||||
|
||||
def _default_epics(device: HashableDevice):
|
||||
"""For EPICS devices, we care about the class and the PV prefix"""
|
||||
if device.deviceConfig is None or "prefix" not in device.deviceConfig:
|
||||
logger.warning(
|
||||
f"Device {device.name} doesn't specify a prefix, reverting to default HashModel"
|
||||
)
|
||||
return _default(device)
|
||||
return (device.deviceClass + device.deviceConfig.get("prefix", "")).encode()
|
||||
|
||||
if device.deviceConfig is None or DEVICE_HASH_MODEL_KEY not in device.deviceConfig:
|
||||
return _default(device)
|
||||
try:
|
||||
hash_model = HashModel[device.deviceConfig[DEVICE_HASH_MODEL_KEY]]
|
||||
except KeyError:
|
||||
logger.warning(
|
||||
f"Device {device.name} has invalid config parameter {DEVICE_HASH_MODEL_KEY}:{device.deviceConfig[DEVICE_HASH_MODEL_KEY]}. Please choose one of: {[m.name for m in HashModel]}"
|
||||
)
|
||||
hash_model = HashModel.DEFAULT
|
||||
|
||||
# Type checking should check that all cases are accounted for, otherwise
|
||||
# the return type declaration for the function will be marked wrong.
|
||||
match hash_model:
|
||||
case HashModel.DEFAULT:
|
||||
return _default(device)
|
||||
case HashModel.DEFAULT_DEVICECONFIG:
|
||||
return _default_deviceconfig(device)
|
||||
case HashModel.DEFAULT_EPICS:
|
||||
return _default_epics(device)
|
||||
|
||||
|
||||
class HashableDevice(Device):
|
||||
source_files: set[str] = set()
|
||||
names: set[str] = set()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def add_name(self) -> HashableDevice:
|
||||
self.names.add(self.name)
|
||||
return self
|
||||
|
||||
def as_normal_device(self):
|
||||
return Device.model_validate(self)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return int(hashlib.md5(_hash_input(self)).hexdigest(), 16)
|
||||
|
||||
def __eq__(self, value: object) -> bool:
|
||||
if not isinstance(value, self.__class__):
|
||||
return False
|
||||
if hash(self) == hash(value):
|
||||
return True
|
||||
return False
|
||||
|
||||
def rich_text(self) -> str:
|
||||
return dedent(
|
||||
f"""
|
||||
<b><u><h2> {self.name}: </h2></u></b>
|
||||
<table>
|
||||
<tr><td> description: </td><td><i> {self.description} </i></td></tr>
|
||||
<tr><td> config: </td><td><i> {self.deviceConfig} </i></td></tr>
|
||||
<tr><td> enabled: </td><td><i> {self.enabled} </i></td></tr>
|
||||
<tr><td> read only: </td><td><i> {self.readOnly} </i></td></tr>
|
||||
</table>
|
||||
"""
|
||||
)
|
||||
|
||||
def add_sources(self, other: HashableDevice):
|
||||
self.source_files.update(other.source_files)
|
||||
|
||||
def add_tags(self, other: HashableDevice):
|
||||
self.deviceTags.update(other.deviceTags)
|
||||
|
||||
def add_names(self, other: HashableDevice):
|
||||
self.names.update(other.names)
|
||||
|
||||
|
||||
class _HashableDeviceSet(set):
|
||||
def __or__(self, value: AbstractSet) -> _HashableDeviceSet:
|
||||
for item in self:
|
||||
if item in value:
|
||||
for other_item in value:
|
||||
if other_item == item:
|
||||
item.add_sources(other_item)
|
||||
item.add_tags(other_item)
|
||||
item.add_names(other_item)
|
||||
for other_item in value:
|
||||
if other_item not in self:
|
||||
self.add(other_item)
|
||||
return self
|
||||
|
||||
|
||||
class DeviceResourceBackend(Protocol):
|
||||
@property
|
||||
def tag_groups(self) -> dict[str, set[HashableDevice]]:
|
||||
"""A dictionary of all availble devices separated by tag groups. The same device may
|
||||
appear more than once (in different groups)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def all_devices(self) -> set[HashableDevice]:
|
||||
"""A set of all availble devices. The same device may not appear more than once."""
|
||||
...
|
||||
|
||||
@property
|
||||
def untagged_devices(self) -> set[HashableDevice]:
|
||||
"""A set of all untagged devices. The same device may not appear more than once."""
|
||||
...
|
||||
|
||||
def tags(self) -> set[str]:
|
||||
"""Returns a set of all the tags in all available devices."""
|
||||
...
|
||||
|
||||
def tag_group(self, tag: str) -> set[HashableDevice]:
|
||||
"""Returns a set of the devices in the tag group with the given key."""
|
||||
...
|
||||
|
||||
|
||||
def _devices_from_file(file: str, include_source: bool = True):
|
||||
data = yaml_load(file, process_includes=False)
|
||||
return _HashableDeviceSet(
|
||||
HashableDevice.model_validate(
|
||||
dev | {"name": name, "source_files": {file} if include_source else set()}
|
||||
)
|
||||
for name, dev in data.items()
|
||||
)
|
||||
|
||||
|
||||
class _ConfigFileBackend(DeviceResourceBackend):
|
||||
def __init__(self) -> None:
|
||||
self._raw_device_set: set[
|
||||
HashableDevice
|
||||
] = self._get_config_from_backup_files() | self._get_configs_from_plugin_files(
|
||||
Path(plugin_repo_path()) / plugin_package_name() / "device_configs/"
|
||||
)
|
||||
self._tag_groups = self._get_tag_groups()
|
||||
|
||||
def _get_config_from_backup_files(self):
|
||||
dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs"
|
||||
files = glob("*.yaml", root_dir=dir)
|
||||
return reduce(
|
||||
operator.or_,
|
||||
map(partial(_devices_from_file, include_source=False), (str(dir / f) for f in files)),
|
||||
)
|
||||
|
||||
def _get_configs_from_plugin_files(self, dir: Path):
|
||||
files = glob("*.yaml", root_dir=dir, recursive=True)
|
||||
return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files)))
|
||||
|
||||
def _get_tag_groups(self) -> dict[str, set[HashableDevice]]:
|
||||
return {
|
||||
tag: set(filter(lambda dev: tag in dev.deviceTags, self._raw_device_set))
|
||||
for tag in self.tags()
|
||||
}
|
||||
|
||||
@property
|
||||
def tag_groups(self):
|
||||
return self._tag_groups
|
||||
|
||||
@property
|
||||
def all_devices(self):
|
||||
return self._raw_device_set
|
||||
|
||||
@property
|
||||
def untagged_devices(self):
|
||||
return {d for d in self._raw_device_set if d.deviceTags == set()}
|
||||
|
||||
def tags(self) -> set[str]:
|
||||
return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set))
|
||||
|
||||
def tag_group(self, tag: str) -> set[HashableDevice]:
|
||||
return self.tag_groups[tag]
|
||||
|
||||
|
||||
def get_backend() -> DeviceResourceBackend:
|
||||
return _ConfigFileBackend()
|
||||
@@ -0,0 +1,185 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
|
||||
HashableDevice,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_tag_group_ui import (
|
||||
Ui_DeviceTagGroup,
|
||||
)
|
||||
|
||||
DEVICE_HASH_ROLE = 101
|
||||
|
||||
|
||||
def _warning_string(spec: HashableDevice):
|
||||
name_warning = (
|
||||
f"Device defined with multiple names! Please check:\n {'\n '.join(spec.names)}\n"
|
||||
if len(spec.names) > 1
|
||||
else ""
|
||||
)
|
||||
source_warning = (
|
||||
f"Device found in multiple source files! Please check:\n {'\n '.join(spec.source_files)}"
|
||||
if len(spec.source_files) > 1
|
||||
else ""
|
||||
)
|
||||
return f"{name_warning}{source_warning}"
|
||||
|
||||
|
||||
class _DeviceEntryWidget(QFrame):
|
||||
# _grid_size = QSize(120, 80)
|
||||
|
||||
def __init__(self, device_spec: HashableDevice, parent=None, **kwargs):
|
||||
super().__init__(parent, **kwargs)
|
||||
self._device_spec = device_spec
|
||||
self.included: bool = False
|
||||
|
||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.setFrameShadow(QFrame.Shadow.Raised)
|
||||
|
||||
self._layout = QVBoxLayout()
|
||||
self._layout.setContentsMargins(5, 5, 5, 5)
|
||||
self.setLayout(self._layout)
|
||||
# self.setMinimumSize(self._grid_size)
|
||||
|
||||
self.setup_title_layout(device_spec)
|
||||
self.check_and_display_warning()
|
||||
|
||||
self.setToolTip(device_spec.rich_text())
|
||||
|
||||
# self.details = QLabel(f"Tags:\n{', '.join(device_spec.deviceTags)}")
|
||||
# self.details.setStyleSheet("QLabel { font-size: 8pt; }")
|
||||
# self.details.setWordWrap(True)
|
||||
# self._layout.addWidget(self.details)
|
||||
|
||||
def setup_title_layout(self, device_spec: HashableDevice):
|
||||
self._title_layout = QHBoxLayout()
|
||||
self._title_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._title_container = QWidget(parent=self)
|
||||
self._title_container.setLayout(self._title_layout)
|
||||
|
||||
self._warning_label = QLabel()
|
||||
self._title_layout.addWidget(self._warning_label)
|
||||
|
||||
self.title = QLabel(device_spec.name)
|
||||
self.title.setToolTip(device_spec.name)
|
||||
self.title.setStyleSheet(self.title_style("#FF0000"))
|
||||
self._title_layout.addWidget(self.title)
|
||||
|
||||
self._layout.addWidget(self._title_container)
|
||||
|
||||
def check_and_display_warning(self):
|
||||
if len(self._device_spec.names) == 1 and len(self._device_spec.source_files) == 1:
|
||||
self._warning_label.setText("")
|
||||
self._warning_label.setToolTip("")
|
||||
else:
|
||||
self._warning_label.setPixmap(material_icon("warning", size=(12, 12), color="#FFAA00"))
|
||||
self._warning_label.setToolTip(_warning_string(self._device_spec))
|
||||
|
||||
@property
|
||||
def device_hash(self):
|
||||
return hash(self._device_spec)
|
||||
|
||||
def title_style(self, color: str) -> str:
|
||||
return f"QLabel {{ color: {color}; font-weight: bold; font-size: 10pt; }}"
|
||||
|
||||
def setTitle(self, text: str):
|
||||
self.title.setText(text)
|
||||
|
||||
def set_included(self, included: bool):
|
||||
self.included = included
|
||||
self.title.setStyleSheet(self.title_style("#00FF00" if included else "#FF0000"))
|
||||
|
||||
|
||||
class _DeviceEntry(NamedTuple):
|
||||
list_item: QListWidgetItem
|
||||
widget: _DeviceEntryWidget
|
||||
|
||||
|
||||
class DeviceTagGroup(ExpandableGroupFrame, Ui_DeviceTagGroup):
|
||||
def __init__(
|
||||
self, parent=None, name: str = "TagGroupTitle", data: set[HashableDevice] = set(), **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setupUi(self)
|
||||
self.title_text = name
|
||||
self._devices: dict[str, _DeviceEntry] = {}
|
||||
for device in data:
|
||||
self._add_item(device)
|
||||
self.device_list.sortItems()
|
||||
self._update_num_included()
|
||||
|
||||
self.add_to_composition_button.clicked.connect(self.test)
|
||||
|
||||
def _add_item(self, device: HashableDevice):
|
||||
item = QListWidgetItem(self.device_list)
|
||||
widget = _DeviceEntryWidget(device, self)
|
||||
item.setSizeHint(QSize(widget.width(), widget.height()))
|
||||
self.device_list.setItemWidget(item, widget)
|
||||
self.device_list.addItem(item)
|
||||
self._devices[device.name] = _DeviceEntry(item, widget)
|
||||
|
||||
def reset_devices_state(self):
|
||||
for dev in self._devices.values():
|
||||
dev.widget.set_included(False)
|
||||
self._update_num_included()
|
||||
|
||||
def set_item_state(self, /, device_hash: int, included: bool):
|
||||
for dev in self._devices.values():
|
||||
if dev.widget.device_hash == device_hash:
|
||||
dev.widget.set_included(included)
|
||||
self._update_num_included()
|
||||
|
||||
def _update_num_included(self):
|
||||
n_included = sum(int(dev.widget.included) for dev in self._devices.values())
|
||||
if n_included == 0:
|
||||
color = "#FF0000"
|
||||
elif n_included == len(self._devices):
|
||||
color = "#00FF00"
|
||||
else:
|
||||
color = "#FFAA00"
|
||||
self.n_included.setText(f"{n_included} / {len(self._devices)}")
|
||||
self.n_included.setStyleSheet(f"QLabel {{ color: {color}; }}")
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self.setMinimumHeight(self.sizeHint().height())
|
||||
self.setMaximumHeight(self.sizeHint().height())
|
||||
|
||||
def get_selection(self) -> set[HashableDevice]:
|
||||
selection = self.device_list.selectedItems()
|
||||
widgets = (w.widget for _, w in self._devices.items() if w.list_item in selection)
|
||||
return set(w._device_spec for w in widgets)
|
||||
|
||||
def test(self, *args):
|
||||
print(self.get_selection())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}: {self.title.text()}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
widget = DeviceTagGroup(name="Tag group 1")
|
||||
for item in [
|
||||
HashableDevice(
|
||||
**{
|
||||
"name": f"test_device_{i}",
|
||||
"deviceClass": "TestDeviceClass",
|
||||
"readoutPriority": "baseline",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
for i in range(5)
|
||||
]:
|
||||
widget._add_item(item)
|
||||
widget._update_num_included()
|
||||
widget.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -0,0 +1,114 @@
|
||||
import math
|
||||
from functools import partial
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QMetaObject, QSize, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListView,
|
||||
QListWidget,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
|
||||
class AutoHeightListWidget(QListWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setViewMode(QListView.ViewMode.ListMode)
|
||||
self.setResizeMode(QListView.ResizeMode.Adjust)
|
||||
self.setWrapping(False)
|
||||
self.setUniformItemSizes(True)
|
||||
self.setMovement(QListView.Movement.Static)
|
||||
self.setAcceptDrops(False)
|
||||
self.setDragEnabled(True)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.setSpacing(5)
|
||||
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self.setMinimumHeight(self._calcSize().height())
|
||||
self.setMaximumHeight(self._calcSize().height())
|
||||
|
||||
def sizeHint(self):
|
||||
return self._calcSize()
|
||||
|
||||
def minimumSizeHint(self):
|
||||
return self._calcSize()
|
||||
|
||||
def _calcSize(self):
|
||||
if self.count() == 0:
|
||||
return super().sizeHint()
|
||||
|
||||
grid = self.gridSize()
|
||||
if not grid.isValid():
|
||||
grid = QSize(100, 100) # fallback
|
||||
|
||||
items_per_row = max(1, self.viewport().width() // grid.width())
|
||||
rows = math.ceil(self.count() / items_per_row)
|
||||
|
||||
height = rows * grid.height() + 2 * self.frameWidth()
|
||||
return QSize(self.viewport().width(), height)
|
||||
|
||||
|
||||
class Ui_DeviceTagGroup(object):
|
||||
def setupUi(self, DeviceTagGroup):
|
||||
if not DeviceTagGroup.objectName():
|
||||
DeviceTagGroup.setObjectName("DeviceTagGroup")
|
||||
DeviceTagGroup.setMinimumWidth(150)
|
||||
self.verticalLayout = QVBoxLayout()
|
||||
self.verticalLayout.setObjectName("verticalLayout")
|
||||
DeviceTagGroup.set_layout(self.verticalLayout)
|
||||
|
||||
title_layout = DeviceTagGroup.get_title_layout()
|
||||
|
||||
self.n_included = QLabel(DeviceTagGroup, text="...")
|
||||
self.n_included.setObjectName("n_included")
|
||||
title_layout.addWidget(self.n_included)
|
||||
|
||||
self.delete_tag_button = QToolButton(DeviceTagGroup)
|
||||
self.delete_tag_button.setObjectName("delete_tag_button")
|
||||
title_layout.addWidget(self.delete_tag_button)
|
||||
|
||||
self.remove_from_composition_button = QToolButton(DeviceTagGroup)
|
||||
self.remove_from_composition_button.setObjectName("remove_from_composition_button")
|
||||
title_layout.addWidget(self.remove_from_composition_button)
|
||||
|
||||
self.add_to_composition_button = QToolButton(DeviceTagGroup)
|
||||
self.add_to_composition_button.setObjectName("add_to_composition_button")
|
||||
title_layout.addWidget(self.add_to_composition_button)
|
||||
|
||||
self.remove_all_button = QToolButton(DeviceTagGroup)
|
||||
self.remove_all_button.setObjectName("remove_all_from_composition_button")
|
||||
title_layout.addWidget(self.remove_all_button)
|
||||
|
||||
self.add_all_button = QToolButton(DeviceTagGroup)
|
||||
self.add_all_button.setObjectName("add_all_to_composition_button")
|
||||
title_layout.addWidget(self.add_all_button)
|
||||
|
||||
self.device_list = AutoHeightListWidget(DeviceTagGroup)
|
||||
self.device_list.setObjectName("device_list")
|
||||
|
||||
self.verticalLayout.addWidget(self.device_list)
|
||||
|
||||
self.set_icons()
|
||||
|
||||
QMetaObject.connectSlotsByName(DeviceTagGroup)
|
||||
|
||||
def set_icons(self):
|
||||
icon = partial(material_icon, size=(15, 15), convert_to_pixmap=False)
|
||||
self.delete_tag_button.setIcon(icon("delete"))
|
||||
self.delete_tag_button.setToolTip("Delete tag group")
|
||||
self.remove_from_composition_button.setIcon(icon("remove"))
|
||||
self.remove_from_composition_button.setToolTip("Remove selected from composition")
|
||||
self.add_to_composition_button.setIcon(icon("add"))
|
||||
self.add_to_composition_button.setToolTip("Add selected to composition")
|
||||
self.remove_all_button.setIcon(icon("chips"))
|
||||
self.remove_all_button.setToolTip("Remove all with this tag from composition")
|
||||
self.add_all_button.setIcon(icon("add_box"))
|
||||
self.add_all_button.setToolTip("Add all with this tag to composition")
|
||||
@@ -0,0 +1,671 @@
|
||||
"""Module with the device table view implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from thefuzz import fuzz
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# Threshold for fuzzy matching, careful with adjusting this. 80 seems good
|
||||
FUZZY_SEARCH_THRESHOLD = 80
|
||||
|
||||
|
||||
class DictToolTipDelegate(QtWidgets.QStyledItemDelegate):
|
||||
"""Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip."""
|
||||
|
||||
def helpEvent(self, event, view, option, index):
|
||||
"""Override to show tooltip when hovering."""
|
||||
if event.type() != QtCore.QEvent.ToolTip:
|
||||
return super().helpEvent(event, view, option, index)
|
||||
model: DeviceFilterProxyModel = index.model()
|
||||
model_index = model.mapToSource(index)
|
||||
row_dict = model.sourceModel().get_row_data(model_index)
|
||||
QtWidgets.QToolTip.showText(event.globalPos(), row_dict["description"], view)
|
||||
return True
|
||||
|
||||
|
||||
class CenterCheckBoxDelegate(DictToolTipDelegate):
|
||||
"""Custom checkbox delegate to center checkboxes in table cells."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
colors = get_accent_colors()
|
||||
self._icon_checked = material_icon(
|
||||
"check_box", size=QtCore.QSize(16, 16), color=colors.default, filled=True
|
||||
)
|
||||
self._icon_unchecked = material_icon(
|
||||
"check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default, filled=True
|
||||
)
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
colors = get_accent_colors()
|
||||
self._icon_checked.setColor(colors.default)
|
||||
self._icon_unchecked.setColor(colors.default)
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
value = index.model().data(index, QtCore.Qt.CheckStateRole)
|
||||
if value is None:
|
||||
super().paint(painter, option, index)
|
||||
return
|
||||
|
||||
# Choose icon based on state
|
||||
pixmap = self._icon_checked if value == QtCore.Qt.Checked else self._icon_unchecked
|
||||
|
||||
# Draw icon centered
|
||||
rect = option.rect
|
||||
pix_rect = pixmap.rect()
|
||||
pix_rect.moveCenter(rect.center())
|
||||
painter.drawPixmap(pix_rect.topLeft(), pixmap)
|
||||
|
||||
def editorEvent(self, event, model, option, index):
|
||||
if event.type() != QtCore.QEvent.MouseButtonRelease:
|
||||
return False
|
||||
current = model.data(index, QtCore.Qt.CheckStateRole)
|
||||
new_state = QtCore.Qt.Unchecked if current == QtCore.Qt.Checked else QtCore.Qt.Checked
|
||||
return model.setData(index, new_state, QtCore.Qt.CheckStateRole)
|
||||
|
||||
|
||||
class WrappingTextDelegate(DictToolTipDelegate):
|
||||
"""Custom delegate for wrapping text in table cells."""
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
text = index.model().data(index, QtCore.Qt.DisplayRole)
|
||||
if not text:
|
||||
return super().paint(painter, option, index)
|
||||
|
||||
painter.save()
|
||||
painter.setClipRect(option.rect)
|
||||
text_option = QtCore.Qt.TextWordWrap | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop
|
||||
painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text)
|
||||
painter.restore()
|
||||
|
||||
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):
|
||||
"""
|
||||
Custom Device Table Model for managing device configurations.
|
||||
|
||||
Sort logic is implemented directly on the data of the table view.
|
||||
"""
|
||||
|
||||
device_added = QtCore.Signal(dict)
|
||||
devices_reset = QtCore.Signal(list)
|
||||
|
||||
def __init__(self, device_config: list[dict] | None = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._device_config = device_config or []
|
||||
self.headers = [
|
||||
"name",
|
||||
"deviceClass",
|
||||
"readoutPriority",
|
||||
"deviceTags",
|
||||
"enabled",
|
||||
"readOnly",
|
||||
]
|
||||
self._checkable_columns_enabled = {"enabled": True, "readOnly": True}
|
||||
|
||||
###############################################
|
||||
########## Overwrite custom Qt methods ########
|
||||
###############################################
|
||||
|
||||
def rowCount(self, parent=QtCore.QModelIndex()) -> int:
|
||||
return len(self._device_config)
|
||||
|
||||
def columnCount(self, parent=QtCore.QModelIndex()) -> int:
|
||||
return len(self.headers)
|
||||
|
||||
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
|
||||
if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal:
|
||||
return self.headers[section]
|
||||
return None
|
||||
|
||||
def get_row_data(self, index: QtCore.QModelIndex) -> dict:
|
||||
"""Return the row data for the given index."""
|
||||
if not index.isValid():
|
||||
return {}
|
||||
return copy.deepcopy(self._device_config[index.row()])
|
||||
|
||||
def data(self, index, role=QtCore.Qt.DisplayRole):
|
||||
"""Return data for the given index and role."""
|
||||
if not index.isValid():
|
||||
return None
|
||||
row, col = index.row(), index.column()
|
||||
key = self.headers[col]
|
||||
value = self._device_config[row].get(key)
|
||||
|
||||
if role == QtCore.Qt.DisplayRole:
|
||||
if key in ("enabled", "readOnly"):
|
||||
return bool(value)
|
||||
if key == "deviceTags":
|
||||
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 ""
|
||||
if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"):
|
||||
return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked
|
||||
if role == QtCore.Qt.TextAlignmentRole:
|
||||
if key in ("enabled", "readOnly"):
|
||||
return QtCore.Qt.AlignCenter
|
||||
return QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
|
||||
if role == QtCore.Qt.FontRole:
|
||||
font = QtGui.QFont()
|
||||
return font
|
||||
return None
|
||||
|
||||
def flags(self, index):
|
||||
"""Flags for the table model."""
|
||||
if not index.isValid():
|
||||
return QtCore.Qt.NoItemFlags
|
||||
key = self.headers[index.column()]
|
||||
|
||||
if key in ("enabled", "readOnly"):
|
||||
base_flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
|
||||
if self._checkable_columns_enabled.get(key, True):
|
||||
return base_flags | QtCore.Qt.ItemIsUserCheckable
|
||||
else:
|
||||
return base_flags # disable editing but still visible
|
||||
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
|
||||
|
||||
def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool:
|
||||
"""
|
||||
Method to set the data of the table.
|
||||
|
||||
Args:
|
||||
index (QModelIndex): The index of the item to modify.
|
||||
value (Any): The new value to set.
|
||||
role (Qt.ItemDataRole): The role of the data being set.
|
||||
|
||||
Returns:
|
||||
bool: True if the data was set successfully, False otherwise.
|
||||
"""
|
||||
if not index.isValid():
|
||||
return False
|
||||
key = self.headers[index.column()]
|
||||
row = index.row()
|
||||
|
||||
if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole:
|
||||
if not self._checkable_columns_enabled.get(key, True):
|
||||
return False # ignore changes if column is disabled
|
||||
self._device_config[row][key] = value == QtCore.Qt.Checked
|
||||
self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole])
|
||||
return True
|
||||
return False
|
||||
|
||||
####################################
|
||||
############ Public methods ########
|
||||
####################################
|
||||
|
||||
def get_device_config(self) -> list[dict]:
|
||||
"""Return the current device config (with checkbox updates applied)."""
|
||||
return self._device_config
|
||||
|
||||
def set_checkbox_enabled(self, column_name: str, enabled: bool):
|
||||
"""
|
||||
Enable/Disable the checkbox column.
|
||||
|
||||
Args:
|
||||
column_name (str): The name of the column to modify.
|
||||
enabled (bool): Whether the checkbox should be enabled or disabled.
|
||||
"""
|
||||
if column_name in self._checkable_columns_enabled:
|
||||
self._checkable_columns_enabled[column_name] = enabled
|
||||
col = self.headers.index(column_name)
|
||||
top_left = self.index(0, col)
|
||||
bottom_right = self.index(self.rowCount() - 1, col)
|
||||
self.dataChanged.emit(
|
||||
top_left, bottom_right, [QtCore.Qt.CheckStateRole, QtCore.Qt.DisplayRole]
|
||||
)
|
||||
|
||||
def set_device_config(self, device_config: list[dict]):
|
||||
"""
|
||||
Replace the device config.
|
||||
|
||||
Args:
|
||||
device_config (list[dict]): The new device config to set.
|
||||
"""
|
||||
self.beginResetModel()
|
||||
self._device_config = list(device_config)
|
||||
self.endResetModel()
|
||||
self.devices_reset.emit(self._device_config)
|
||||
|
||||
@SafeSlot(dict)
|
||||
def add_device(self, device: dict):
|
||||
"""
|
||||
Add an extra device to the device config at the bottom.
|
||||
|
||||
Args:
|
||||
device (dict): The device configuration to add.
|
||||
"""
|
||||
row = len(self._device_config)
|
||||
self.beginInsertRows(QtCore.QModelIndex(), row, row)
|
||||
self._device_config.append(device)
|
||||
self.endInsertRows()
|
||||
|
||||
@SafeSlot(int)
|
||||
def remove_device_by_row(self, row: int):
|
||||
"""
|
||||
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._device_config.pop(row)
|
||||
self.endRemoveRows()
|
||||
|
||||
@SafeSlot(list)
|
||||
def remove_devices_by_rows(self, rows: list[int]):
|
||||
"""
|
||||
Remove multiple device rows by their indices.
|
||||
|
||||
Args:
|
||||
rows (list[int]): The indices of the device rows to remove.
|
||||
"""
|
||||
for row in sorted(rows, reverse=True):
|
||||
self.remove_device_by_row(row)
|
||||
|
||||
@SafeSlot(str)
|
||||
def remove_device_by_name(self, name: str):
|
||||
"""
|
||||
Remove one device row by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the device to remove.
|
||||
"""
|
||||
for row, device in enumerate(self._device_config):
|
||||
if device.get("name") == name:
|
||||
self.remove_device_by_row(row)
|
||||
break
|
||||
|
||||
|
||||
class BECTableView(QtWidgets.QTableView):
|
||||
"""Table View with custom keyPressEvent to delete rows with backspace or delete key"""
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
"""
|
||||
Delete selected rows with backspace or delete key
|
||||
|
||||
Args:
|
||||
event: keyPressEvent
|
||||
"""
|
||||
if event.key() not in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete):
|
||||
return super().keyPressEvent(event)
|
||||
|
||||
proxy_indexes = self.selectedIndexes()
|
||||
if not proxy_indexes:
|
||||
return
|
||||
|
||||
# Get unique rows (proxy indices) in reverse order so removal indexes stay valid
|
||||
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
|
||||
# Delegate confirmation and removal to helper
|
||||
removed = self._confirm_and_remove_rows(model, source_rows)
|
||||
if not removed:
|
||||
return
|
||||
|
||||
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.
|
||||
|
||||
Returns True if rows were removed, False otherwise.
|
||||
"""
|
||||
cfg = model.get_device_config()
|
||||
names = [str(cfg[r].get("name", "<unknown>")) for r in sorted(source_rows)]
|
||||
|
||||
msg = QtWidgets.QMessageBox(self)
|
||||
msg.setIcon(QtWidgets.QMessageBox.Warning)
|
||||
msg.setWindowTitle("Confirm remove devices")
|
||||
if len(names) == 1:
|
||||
msg.setText(f"Remove device '{names[0]}'?")
|
||||
else:
|
||||
msg.setText(f"Remove {len(names)} devices?")
|
||||
msg.setInformativeText("\n".join(names))
|
||||
msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel)
|
||||
msg.setDefaultButton(QtWidgets.QMessageBox.Cancel)
|
||||
|
||||
res = msg.exec_()
|
||||
if res == QtWidgets.QMessageBox.Ok:
|
||||
model.remove_devices_by_rows(source_rows)
|
||||
# TODO add signal for removed devices
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._hidden_rows = set()
|
||||
self._filter_text = ""
|
||||
self._enable_fuzzy = True
|
||||
self._filter_columns = [0, 1] # name and deviceClass for search
|
||||
|
||||
def hide_rows(self, row_indices: list[int]):
|
||||
"""
|
||||
Hide specific rows in the model.
|
||||
|
||||
Args:
|
||||
row_indices (list[int]): List of row indices to hide.
|
||||
"""
|
||||
self._hidden_rows.update(row_indices)
|
||||
self.invalidateFilter()
|
||||
|
||||
def show_rows(self, row_indices: list[int]):
|
||||
"""
|
||||
Show specific rows in the model.
|
||||
|
||||
Args:
|
||||
row_indices (list[int]): List of row indices to show.
|
||||
"""
|
||||
self._hidden_rows.difference_update(row_indices)
|
||||
self.invalidateFilter()
|
||||
|
||||
def show_all_rows(self):
|
||||
"""
|
||||
Show all rows in the model.
|
||||
"""
|
||||
self._hidden_rows.clear()
|
||||
self.invalidateFilter()
|
||||
|
||||
@SafeSlot(int)
|
||||
def disable_fuzzy_search(self, enabled: int):
|
||||
self._enable_fuzzy = not bool(enabled)
|
||||
self.invalidateFilter()
|
||||
|
||||
def setFilterText(self, text: str):
|
||||
self._filter_text = text.lower()
|
||||
self.invalidateFilter()
|
||||
|
||||
def filterAcceptsRow(self, source_row: int, source_parent) -> bool:
|
||||
# No hidden rows, and no filter text
|
||||
if not self._filter_text and not self._hidden_rows:
|
||||
return True
|
||||
# Hide hidden rows
|
||||
if source_row in self._hidden_rows:
|
||||
return False
|
||||
# Check the filter text for each row
|
||||
model = self.sourceModel()
|
||||
text = self._filter_text.lower()
|
||||
for column in self._filter_columns:
|
||||
index = model.index(source_row, column, source_parent)
|
||||
data = str(model.data(index, QtCore.Qt.DisplayRole) or "")
|
||||
if self._enable_fuzzy is True:
|
||||
match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower())
|
||||
if match_ratio >= FUZZY_SEARCH_THRESHOLD:
|
||||
return True
|
||||
else:
|
||||
if text in data.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||
"""Device Table View for the device manager."""
|
||||
|
||||
selected_device = QtCore.Signal(dict)
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
devices_removed = QtCore.Signal(list)
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent, theme_update=True)
|
||||
|
||||
self.layout = QtWidgets.QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.layout.setSpacing(4)
|
||||
|
||||
# Setup table view
|
||||
self._setup_table_view()
|
||||
# Setup search view, needs table proxy to be iniditate
|
||||
self._setup_search()
|
||||
# Add widgets to main layout
|
||||
self.layout.addLayout(self.search_controls)
|
||||
self.layout.addWidget(self.table)
|
||||
|
||||
def _setup_search(self):
|
||||
"""Create components related to the search functionality"""
|
||||
|
||||
# Create search bar
|
||||
self.search_layout = QtWidgets.QHBoxLayout()
|
||||
self.search_label = QtWidgets.QLabel("Search:")
|
||||
self.search_input = QtWidgets.QLineEdit()
|
||||
self.search_input.setPlaceholderText(
|
||||
"Filter devices (approximate matching)..."
|
||||
) # Default to fuzzy search
|
||||
self.search_input.setClearButtonEnabled(True)
|
||||
self.search_input.textChanged.connect(self.proxy.setFilterText)
|
||||
self.search_layout.addWidget(self.search_label)
|
||||
self.search_layout.addWidget(self.search_input)
|
||||
|
||||
# Add exact match toggle
|
||||
self.fuzzy_layout = QtWidgets.QHBoxLayout()
|
||||
self.fuzzy_label = QtWidgets.QLabel("Exact Match:")
|
||||
self.fuzzy_is_disabled = QtWidgets.QCheckBox()
|
||||
|
||||
self.fuzzy_is_disabled.stateChanged.connect(self.proxy.disable_fuzzy_search)
|
||||
self.fuzzy_is_disabled.setToolTip(
|
||||
"Enable approximate matching (OFF) and exact matching (ON)"
|
||||
)
|
||||
self.fuzzy_label.setToolTip("Enable approximate matching (OFF) and exact matching (ON)")
|
||||
self.fuzzy_layout.addWidget(self.fuzzy_label)
|
||||
self.fuzzy_layout.addWidget(self.fuzzy_is_disabled)
|
||||
self.fuzzy_layout.addStretch()
|
||||
|
||||
# Add both search components to the layout
|
||||
self.search_controls = QtWidgets.QHBoxLayout()
|
||||
self.search_controls.addLayout(self.search_layout)
|
||||
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
|
||||
self.search_controls.addLayout(self.fuzzy_layout)
|
||||
QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0))
|
||||
|
||||
def _setup_table_view(self) -> None:
|
||||
"""Setup the table view."""
|
||||
# Model + Proxy
|
||||
self.table = BECTableView(self)
|
||||
self.model = DeviceTableModel(parent=self.table)
|
||||
self.proxy = DeviceFilterProxyModel(parent=self.table)
|
||||
self.proxy.setSourceModel(self.model)
|
||||
self.table.setModel(self.proxy)
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
# Delegates
|
||||
self.checkbox_delegate = CenterCheckBoxDelegate(self.table)
|
||||
self.wrap_delegate = WrappingTextDelegate(self.table)
|
||||
self.tool_tip_delegate = DictToolTipDelegate(self.table)
|
||||
self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name
|
||||
self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass
|
||||
self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority
|
||||
self.table.setItemDelegateForColumn(3, self.wrap_delegate) # deviceTags
|
||||
self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # enabled
|
||||
self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # readOnly
|
||||
|
||||
# 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.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name
|
||||
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass
|
||||
header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority
|
||||
header.setSectionResizeMode(3, QtWidgets.QHeaderView.Stretch) # deviceTags
|
||||
header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # enabled
|
||||
header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) # readOnly
|
||||
|
||||
self.table.setColumnWidth(3, 70)
|
||||
self.table.setColumnWidth(4, 70)
|
||||
|
||||
# Ensure column widths stay fixed
|
||||
header.setMinimumSectionSize(70)
|
||||
header.setDefaultSectionSize(90)
|
||||
|
||||
# Enable resizing of column
|
||||
header.sectionResized.connect(self.on_table_resized)
|
||||
|
||||
# Selection behavior
|
||||
self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
|
||||
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)
|
||||
|
||||
# QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0))
|
||||
|
||||
def device_config(self) -> list[dict]:
|
||||
"""Get the device config."""
|
||||
return self.model.get_device_config()
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
self.checkbox_delegate.apply_theme(theme)
|
||||
|
||||
######################################
|
||||
########### Slot API #################
|
||||
######################################
|
||||
|
||||
@SafeSlot(int, int, int)
|
||||
def on_table_resized(self, column, old_width, new_width):
|
||||
"""Handle changes to the table column resizing."""
|
||||
if column != len(self.model.headers) - 1:
|
||||
return
|
||||
|
||||
for row in range(self.table.model().rowCount()):
|
||||
index = self.table.model().index(row, column)
|
||||
delegate = self.table.itemDelegate(index)
|
||||
option = QtWidgets.QStyleOptionViewItem()
|
||||
height = 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
|
||||
|
||||
source_indexes = [self.proxy.mapToSource(idx) for idx in selected_indexes]
|
||||
source_rows = {idx.row() for idx in source_indexes}
|
||||
# Ignore if multiple are selected
|
||||
if len(source_rows) > 1:
|
||||
self.selected_device.emit({})
|
||||
return
|
||||
|
||||
# Get the single row
|
||||
(row,) = source_rows
|
||||
source_index = self.model.index(row, 0) # pick column 0 or whichever
|
||||
device = self.model.get_row_data(source_index)
|
||||
self.selected_device.emit(device)
|
||||
|
||||
@SafeSlot(QtCore.QModelIndex)
|
||||
def _on_row_selected(self, index: QtCore.QModelIndex):
|
||||
"""Handle row selection in the device table."""
|
||||
if not index.isValid():
|
||||
return
|
||||
source_index = self.proxy.mapToSource(index)
|
||||
device = self.model.get_device_at_index(source_index)
|
||||
self.selected_device.emit(device)
|
||||
|
||||
######################################
|
||||
##### Ext. Slot API #################
|
||||
######################################
|
||||
|
||||
@SafeSlot(list)
|
||||
def set_device_config(self, config: list[dict]):
|
||||
"""
|
||||
Set the device config.
|
||||
|
||||
Args:
|
||||
config (list[dict]): The device config to set.
|
||||
"""
|
||||
self.model.set_device_config(config)
|
||||
|
||||
@SafeSlot()
|
||||
def clear_device_config(self):
|
||||
"""
|
||||
Clear the device config.
|
||||
"""
|
||||
self.model.set_device_config([])
|
||||
|
||||
@SafeSlot(dict)
|
||||
def add_device(self, device: dict):
|
||||
"""
|
||||
Add a device to the config.
|
||||
|
||||
Args:
|
||||
device (dict): The device to add.
|
||||
"""
|
||||
self.model.add_device(device)
|
||||
|
||||
@SafeSlot(int)
|
||||
@SafeSlot(str)
|
||||
def remove_device(self, dev: int | str):
|
||||
"""
|
||||
Remove the device from the config either by row id, or device name.
|
||||
|
||||
Args:
|
||||
dev (int | str): The device to remove, either by row id or device name.
|
||||
"""
|
||||
if isinstance(dev, int):
|
||||
# TODO test this properly, check with proxy index and source index
|
||||
# Use the proxy model to map to the correct row
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
window = DeviceTableView()
|
||||
# pylint: disable=protected-access
|
||||
config = window.client.device_manager._get_redis_device_config()
|
||||
window.set_device_config(config)
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Module with a config view for the device manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
|
||||
|
||||
class DMConfigView(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.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)
|
||||
|
||||
def _customize_overlay(self):
|
||||
self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
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_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 not device:
|
||||
text = ""
|
||||
self.stacked_layout.setCurrentWidget(self._overlay_widget)
|
||||
else:
|
||||
text = yaml.dump(device, default_flow_style=False)
|
||||
self.stacked_layout.setCurrentWidget(self.monaco_editor)
|
||||
self.monaco_editor.set_readonly(False) # Enable editing
|
||||
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,333 @@
|
||||
"""Module to run a static test for the current config and see if it is valid."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
import bec_lib
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ValidationStatus(int, enum.Enum):
|
||||
"""Validation status for device configurations."""
|
||||
|
||||
UNKNOWN = 0 # colors.default
|
||||
ERROR = 1 # colors.emergency
|
||||
VALID = 2 # colors.highlight
|
||||
CANT_CONNECT = 3 # colors.warning
|
||||
CONNECTED = 4 # colors.success
|
||||
|
||||
|
||||
class DeviceValidationListItem(QtWidgets.QWidget):
|
||||
"""Custom list item widget showing device name and validation status."""
|
||||
|
||||
status_changed = QtCore.Signal(int) # Signal emitted when status changes -> ValidationStatus
|
||||
# Signal emitted when device was validated with name, success, msg
|
||||
device_validated = QtCore.Signal(str, str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_config: dict[str, dict],
|
||||
status: ValidationStatus,
|
||||
status_icons: dict[ValidationStatus, QtGui.QPixmap],
|
||||
validate_icon: QtGui.QPixmap,
|
||||
parent=None,
|
||||
static_device_test=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
if len(device_config.keys()) > 1:
|
||||
logger.warning(
|
||||
f"Multiple devices found for config: {list(device_config.keys())}, using first one"
|
||||
)
|
||||
self._static_device_test = static_device_test
|
||||
self.device_name = list(device_config.keys())[0]
|
||||
self.device_config = device_config
|
||||
self.status: ValidationStatus = status
|
||||
colors = get_accent_colors()
|
||||
self._status_icon = status_icons
|
||||
self._validate_icon = validate_icon
|
||||
self._setup_ui()
|
||||
self._update_status_indicator()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the UI for the list item."""
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
# Device name label
|
||||
self.name_label = QtWidgets.QLabel(self.device_name)
|
||||
self.name_label.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(self.name_label)
|
||||
|
||||
# Make sure status is on the right
|
||||
layout.addStretch()
|
||||
self.request_validation_button = QtWidgets.QPushButton("Validate")
|
||||
self.request_validation_button.setIcon(self._validate_icon)
|
||||
if self._static_device_test is None:
|
||||
self.request_validation_button.setDisabled(True)
|
||||
else:
|
||||
self.request_validation_button.clicked.connect(self.on_request_validation)
|
||||
# self.request_validation_button.setVisible(False) -> Hide it??
|
||||
layout.addWidget(self.request_validation_button)
|
||||
# Status indicator
|
||||
self.status_indicator = QtWidgets.QLabel()
|
||||
self._update_status_indicator()
|
||||
layout.addWidget(self.status_indicator)
|
||||
|
||||
@SafeSlot()
|
||||
def on_request_validation(self):
|
||||
"""Handle validate button click."""
|
||||
if self._static_device_test is None:
|
||||
logger.warning("Static device test not available.")
|
||||
return
|
||||
self._static_device_test.config = self.device_config
|
||||
# TODO logic if connect is allowed
|
||||
ret = self._static_device_test.run_with_list_output(connect=False)[0]
|
||||
if ret.success:
|
||||
self.set_status(ValidationStatus.VALID)
|
||||
else:
|
||||
self.set_status(ValidationStatus.ERROR)
|
||||
self.device_validated.emit(ret.name, ret.message)
|
||||
|
||||
def _update_status_indicator(self):
|
||||
"""Update the status indicator color based on validation status."""
|
||||
self.status_indicator.setPixmap(self._status_icon[self.status])
|
||||
|
||||
def set_status(self, status: ValidationStatus):
|
||||
"""Update the validation status."""
|
||||
self.status = status
|
||||
self._update_status_indicator()
|
||||
self.status_changed.emit(self.status)
|
||||
|
||||
def get_status(self) -> ValidationStatus:
|
||||
"""Get the current validation status."""
|
||||
return self.status
|
||||
|
||||
|
||||
class DeviceManagerOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
|
||||
config_changed = QtCore.Signal(
|
||||
dict, dict
|
||||
) # Signal emitted when the device config changed, new_config, old_config
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent=parent, client=client)
|
||||
if not READY_TO_TEST:
|
||||
self._set_disabled()
|
||||
static_device_test = None
|
||||
else:
|
||||
from ophyd_devices.utils.static_device_test import StaticDeviceTest
|
||||
|
||||
static_device_test = StaticDeviceTest(config_dict={})
|
||||
self._static_device_test = static_device_test
|
||||
self._device_config: dict[str, dict] = {}
|
||||
self._main_layout = QtWidgets.QVBoxLayout(self)
|
||||
self._main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._main_layout.setSpacing(4)
|
||||
|
||||
# Setup icons
|
||||
colors = get_accent_colors()
|
||||
self._validate_icon = material_icon(
|
||||
icon_name="play_arrow", color=colors.default, filled=True
|
||||
)
|
||||
self._status_icons = {
|
||||
ValidationStatus.UNKNOWN: material_icon(
|
||||
icon_name="circle", size=(12, 12), color=colors.default, filled=True
|
||||
),
|
||||
ValidationStatus.ERROR: material_icon(
|
||||
icon_name="circle", size=(12, 12), color=colors.emergency, filled=True
|
||||
),
|
||||
ValidationStatus.VALID: material_icon(
|
||||
icon_name="circle", size=(12, 12), color=colors.highlight, filled=True
|
||||
),
|
||||
ValidationStatus.CANT_CONNECT: material_icon(
|
||||
icon_name="circle", size=(12, 12), color=colors.warning, filled=True
|
||||
),
|
||||
ValidationStatus.CONNECTED: material_icon(
|
||||
icon_name="circle", size=(12, 12), color=colors.success, filled=True
|
||||
),
|
||||
}
|
||||
|
||||
self.setLayout(self._main_layout)
|
||||
|
||||
# splitter
|
||||
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
|
||||
self._main_layout.addWidget(self.splitter)
|
||||
|
||||
# Add custom list
|
||||
self.setup_device_validation_list()
|
||||
|
||||
# Setup text box
|
||||
self.setup_text_box()
|
||||
|
||||
# Connect signals
|
||||
self.config_changed.connect(self.on_config_updated)
|
||||
|
||||
@SafeSlot(list)
|
||||
def on_device_config_update(self, config: list[dict]):
|
||||
old_cfg = self._device_config
|
||||
self._device_config = self._compile_device_config_list(config)
|
||||
self.config_changed.emit(self._device_config, old_cfg)
|
||||
|
||||
def _compile_device_config_list(self, config: list[dict]) -> dict[str, dict]:
|
||||
return {dev["name"]: {k: v for k, v in dev.items() if k != "name"} for dev in config}
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_config_updated(self, new_config: dict, old_config: dict):
|
||||
"""Handle config updates and refresh the validation list."""
|
||||
# Find differences for potential re-validation
|
||||
diffs = self._find_diffs(new_config, old_config)
|
||||
# Check diff first
|
||||
for diff in diffs:
|
||||
if not diff:
|
||||
continue
|
||||
if len(diff) > 1:
|
||||
logger.warning(f"Multiple devices found in diff: {diff}, using first one")
|
||||
name = list(diff.keys())[0]
|
||||
if name in self.client.device_manager.devices:
|
||||
status = ValidationStatus.CONNECTED
|
||||
else:
|
||||
status = ValidationStatus.UNKNOWN
|
||||
if self.get_device_status(diff) is None:
|
||||
self.add_device(diff, status)
|
||||
else:
|
||||
self.update_device_status(diff, status)
|
||||
|
||||
def _find_diffs(self, new_config: dict, old_config: dict) -> list[dict]:
|
||||
"""
|
||||
Return list of keys/paths where d1 and d2 differ. This goes recursively through the dictionary.
|
||||
|
||||
Args:
|
||||
new_config: The first dictionary to compare.
|
||||
old_config: The second dictionary to compare.
|
||||
"""
|
||||
diffs = []
|
||||
keys = set(new_config.keys()) | set(old_config.keys())
|
||||
for k in keys:
|
||||
if k not in old_config: # New device
|
||||
diffs.append({k: new_config[k]})
|
||||
continue
|
||||
if k not in new_config: # Removed device
|
||||
diffs.append({k: old_config[k]})
|
||||
continue
|
||||
# Compare device config if exists in both
|
||||
v1, v2 = old_config[k], new_config[k]
|
||||
if isinstance(v1, dict) and isinstance(v2, dict):
|
||||
if self._find_diffs(v2, v1): # recurse: something inside changed
|
||||
diffs.append({k: new_config[k]})
|
||||
elif v1 != v2:
|
||||
diffs.append({k: new_config[k]})
|
||||
return diffs
|
||||
|
||||
def setup_device_validation_list(self):
|
||||
"""Setup the device validation list."""
|
||||
# Create the custom validation list widget
|
||||
self.validation_list = QtWidgets.QListWidget()
|
||||
self.validation_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
|
||||
self.splitter.addWidget(self.validation_list)
|
||||
# self._main_layout.addWidget(self.validation_list)
|
||||
|
||||
def setup_text_box(self):
|
||||
"""Setup the text box for device validation messages."""
|
||||
self.validation_text_box = QtWidgets.QTextEdit()
|
||||
self.validation_text_box.setReadOnly(True)
|
||||
self.splitter.addWidget(self.validation_text_box)
|
||||
# self._main_layout.addWidget(self.validation_text_box)
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def on_device_validated(self, device_name: str, message: str):
|
||||
"""Handle device validation results."""
|
||||
text = f"Device {device_name} was validated. Message: {message}"
|
||||
self.validation_text_box.setText(text)
|
||||
|
||||
def _set_disabled(self) -> None:
|
||||
"""Disable the full view"""
|
||||
self.setDisabled(True)
|
||||
|
||||
def add_device(
|
||||
self, device_config: dict[str, dict], status: ValidationStatus = ValidationStatus.UNKNOWN
|
||||
):
|
||||
"""Add a device to the validation list."""
|
||||
# Create the custom widget
|
||||
item_widget = DeviceValidationListItem(
|
||||
device_config=device_config,
|
||||
status=status,
|
||||
status_icons=self._status_icons,
|
||||
validate_icon=self._validate_icon,
|
||||
static_device_test=self._static_device_test,
|
||||
)
|
||||
|
||||
# Create a list widget item
|
||||
list_item = QtWidgets.QListWidgetItem()
|
||||
list_item.setSizeHint(item_widget.sizeHint())
|
||||
|
||||
# Add item to list and set custom widget
|
||||
self.validation_list.addItem(list_item)
|
||||
self.validation_list.setItemWidget(list_item, item_widget)
|
||||
item_widget.device_validated.connect(self.on_device_validated)
|
||||
|
||||
def update_device_status(self, device_config: dict[str, dict], status: ValidationStatus):
|
||||
"""Update the validation status for a specific device."""
|
||||
for i in range(self.validation_list.count()):
|
||||
item = self.validation_list.item(i)
|
||||
widget = self.validation_list.itemWidget(item)
|
||||
if (
|
||||
isinstance(widget, DeviceValidationListItem)
|
||||
and widget.device_config == device_config
|
||||
):
|
||||
widget.set_status(status)
|
||||
break
|
||||
|
||||
def clear_devices(self):
|
||||
"""Clear all devices from the list."""
|
||||
self.validation_list.clear()
|
||||
|
||||
def get_device_status(self, device_config: dict[str, dict]) -> ValidationStatus | None:
|
||||
"""Get the validation status for a specific device."""
|
||||
for i in range(self.validation_list.count()):
|
||||
item = self.validation_list.item(i)
|
||||
widget = self.validation_list.itemWidget(item)
|
||||
if (
|
||||
isinstance(widget, DeviceValidationListItem)
|
||||
and widget.device_config == device_config
|
||||
):
|
||||
return widget.get_status()
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
# pylint: disable=ungrouped-imports
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
device_manager_ophyd_test = DeviceManagerOphydTest()
|
||||
cfg = device_manager_ophyd_test.client.device_manager._get_redis_device_config()
|
||||
cfg.append({"name": "Wrong_Device", "type": "test"})
|
||||
device_manager_ophyd_test.on_device_config_update(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_())
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
This module provides an implementation for the device config view.
|
||||
The widget is the entry point for users to edit device configurations.
|
||||
"""
|
||||
@@ -45,7 +45,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
Widget to submit new scans to the queue.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["remove", "screenshot"]
|
||||
USER_ACCESS = ["attach", "detach", "screenshot"]
|
||||
PLUGIN = True
|
||||
ICON_NAME = "tune"
|
||||
ARG_BOX_POSITION: int = 2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Literal
|
||||
from typing import Literal, Sequence
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
@@ -36,7 +36,7 @@ class ScanArgType:
|
||||
BOOL = "bool"
|
||||
STR = "str"
|
||||
DEVICEBASE = "DeviceBase"
|
||||
LITERALS = "dict"
|
||||
LITERALS_DICT = "dict" # Used when the type is provided as a dict with Literal key
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
@@ -83,6 +83,39 @@ class ScanSpinBox(QSpinBox):
|
||||
self.setValue(default)
|
||||
|
||||
|
||||
class ScanLiteralsComboBox(QComboBox):
|
||||
def __init__(
|
||||
self, parent=None, arg_name: str | None = None, default: str | None = None, *args, **kwargs
|
||||
):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self.arg_name = arg_name
|
||||
self.default = default
|
||||
if default is not None:
|
||||
self.setCurrentText(default)
|
||||
|
||||
def set_literals(self, literals: Sequence[str | int | float | None]) -> None:
|
||||
"""
|
||||
Set the list of literals for the combo box.
|
||||
|
||||
Args:
|
||||
literals: List of literal values (can be strings, integers, floats or None)
|
||||
"""
|
||||
self.clear()
|
||||
literals = set(literals) # Remove duplicates
|
||||
if None in literals:
|
||||
literals.remove(None)
|
||||
self.addItem("")
|
||||
|
||||
self.addItems([str(value) for value in literals])
|
||||
|
||||
# find index of the default value
|
||||
index = max(self.findText(str(self.default)), 0)
|
||||
self.setCurrentIndex(index)
|
||||
|
||||
def get_value(self) -> str | None:
|
||||
return self.currentText() if self.currentText() else None
|
||||
|
||||
|
||||
class ScanDoubleSpinBox(QDoubleSpinBox):
|
||||
def __init__(
|
||||
self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs
|
||||
@@ -137,7 +170,7 @@ class ScanGroupBox(QGroupBox):
|
||||
ScanArgType.INT: ScanSpinBox,
|
||||
ScanArgType.BOOL: ScanCheckBox,
|
||||
ScanArgType.STR: ScanLineEdit,
|
||||
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
|
||||
ScanArgType.LITERALS_DICT: ScanLiteralsComboBox,
|
||||
}
|
||||
|
||||
device_selected = Signal(str)
|
||||
@@ -226,7 +259,11 @@ class ScanGroupBox(QGroupBox):
|
||||
for column_index, item in enumerate(group_inputs):
|
||||
arg_name = item.get("name", None)
|
||||
default = item.get("default", None)
|
||||
widget_class = self.WIDGET_HANDLER.get(item["type"], None)
|
||||
item_type = item.get("type", None)
|
||||
if isinstance(item_type, dict) and "Literal" in item_type:
|
||||
widget_class = self.WIDGET_HANDLER.get(ScanArgType.LITERALS_DICT, None)
|
||||
else:
|
||||
widget_class = self.WIDGET_HANDLER.get(item["type"], None)
|
||||
if widget_class is None:
|
||||
logger.error(
|
||||
f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'"
|
||||
@@ -239,6 +276,8 @@ class ScanGroupBox(QGroupBox):
|
||||
widget.set_device_filter(BECDeviceFilter.DEVICE)
|
||||
self.selected_devices[widget] = ""
|
||||
widget.device_selected.connect(self.emit_device_selected)
|
||||
if isinstance(widget, ScanLiteralsComboBox):
|
||||
widget.set_literals(item["type"].get("Literal", []))
|
||||
tooltip = item.get("tooltip", None)
|
||||
if tooltip is not None:
|
||||
widget.setToolTip(item["tooltip"])
|
||||
@@ -336,6 +375,8 @@ class ScanGroupBox(QGroupBox):
|
||||
widget = self.layout.itemAtPosition(1, i).widget()
|
||||
if isinstance(widget, DeviceLineEdit) and device_object:
|
||||
value = widget.get_current_device().name
|
||||
elif isinstance(widget, ScanLiteralsComboBox):
|
||||
value = widget.get_value()
|
||||
else:
|
||||
value = WidgetIO.get_value(widget)
|
||||
kwargs[widget.arg_name] = value
|
||||
|
||||
@@ -32,6 +32,9 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"set_vim_mode_enabled",
|
||||
"set_lsp_header",
|
||||
"get_lsp_header",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
]
|
||||
|
||||
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
|
||||
|
||||
@@ -21,7 +21,16 @@ class WebsiteWidget(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
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__(
|
||||
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
|
||||
|
||||
@@ -115,6 +115,8 @@ class Heatmap(ImageBase):
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
|
||||
@@ -91,6 +91,8 @@ class Image(ImageBase):
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
|
||||
@@ -128,6 +128,8 @@ class MotorMap(PlotBase):
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# motor_map specific
|
||||
"color",
|
||||
|
||||
@@ -96,6 +96,8 @@ class MultiWaveform(PlotBase):
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# MultiWaveform Specific RPC Access
|
||||
"highlighted_index",
|
||||
|
||||
@@ -84,6 +84,8 @@ class ScatterWaveform(PlotBase):
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# Scatter Waveform Specific RPC Access
|
||||
"main_curve",
|
||||
|
||||
@@ -63,6 +63,10 @@ class Waveform(PlotBase):
|
||||
RPC = True
|
||||
ICON_NAME = "show_chart"
|
||||
USER_ACCESS = [
|
||||
# BECWidget Base Class
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
# General PlotBase Settings
|
||||
"_config_dict",
|
||||
"enable_toolbar",
|
||||
@@ -105,7 +109,6 @@ class Waveform(PlotBase):
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
"screenshot",
|
||||
# Waveform Specific RPC Access
|
||||
"curves",
|
||||
"x_mode",
|
||||
|
||||
@@ -96,6 +96,9 @@ class RingProgressBar(BECWidget, QWidget):
|
||||
"set_diameter",
|
||||
"reset_diameter",
|
||||
"enable_auto_updates",
|
||||
"attach",
|
||||
"detach",
|
||||
"screenshot",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -76,7 +76,7 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
|
||||
|
||||
PLUGIN = True
|
||||
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)
|
||||
bec_core_state = Signal(str)
|
||||
|
||||
@@ -11,19 +11,13 @@ from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ConfigAction, ScanStatusMessage
|
||||
from bec_qthemes import material_icon
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QSize, QThreadPool, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QFileDialog,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from qtpy.QtCore import QThreadPool, Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.cli.rpc.rpc_register import RPCRegister
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames
|
||||
from bec_widgets.utils.ui_loader import UILoader
|
||||
from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
|
||||
from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import (
|
||||
@@ -59,7 +53,8 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
self._q_threadpool = QThreadPool()
|
||||
self.ui = None
|
||||
self.init_ui()
|
||||
self.dev_list: QListWidget = self.ui.device_list
|
||||
self.dev_list = ListOfExpandableFrames(self, DeviceItem)
|
||||
self.ui.verticalLayout.addWidget(self.dev_list)
|
||||
self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
|
||||
self.proxy_device_update = SignalProxy(
|
||||
self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list
|
||||
@@ -132,25 +127,15 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
|
||||
def init_device_list(self):
|
||||
self.dev_list.clear()
|
||||
self._device_items: dict[str, QListWidgetItem] = {}
|
||||
|
||||
with RPCRegister.delayed_broadcast():
|
||||
for device, device_obj in self.dev.items():
|
||||
self._add_item_to_list(device, device_obj)
|
||||
|
||||
def _add_item_to_list(self, device: str, device_obj):
|
||||
def _updatesize(item: QListWidgetItem, device_item: DeviceItem):
|
||||
device_item.adjustSize()
|
||||
item.setSizeHint(QSize(device_item.width(), device_item.height()))
|
||||
logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}")
|
||||
|
||||
def _remove_item(item: QListWidgetItem):
|
||||
self.dev_list.takeItem(self.dev_list.row(item))
|
||||
del self._device_items[device]
|
||||
self.dev_list.sortItems()
|
||||
|
||||
item = QListWidgetItem(self.dev_list)
|
||||
device_item = DeviceItem(
|
||||
device_item = self.dev_list.add_item(
|
||||
id=device,
|
||||
parent=self,
|
||||
device=device,
|
||||
devices=self.dev,
|
||||
@@ -158,18 +143,11 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
config_helper=self._config_helper,
|
||||
q_threadpool=self._q_threadpool,
|
||||
)
|
||||
device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item))
|
||||
device_item.imminent_deletion.connect(partial(_remove_item, item))
|
||||
|
||||
self.editing_enabled.connect(device_item.set_editable)
|
||||
self.device_update.connect(device_item.config_update)
|
||||
tooltip = self.dev[device]._config.get("description", "")
|
||||
device_item.setToolTip(tooltip)
|
||||
device_item.broadcast_size_hint.connect(item.setSizeHint)
|
||||
item.setSizeHint(device_item.sizeHint())
|
||||
|
||||
self.dev_list.setItemWidget(item, device_item)
|
||||
self.dev_list.addItem(item)
|
||||
self._device_items[device] = item
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def scan_status_changed(self, scan_info: dict, _: dict):
|
||||
@@ -200,18 +178,16 @@ class DeviceBrowser(BECWidget, QWidget):
|
||||
"""
|
||||
filter_text = self.ui.filter_input.text()
|
||||
for device in self.dev:
|
||||
if device not in self._device_items:
|
||||
if device not in self.dev_list:
|
||||
# it is possible the device has just been added to the config
|
||||
self._add_item_to_list(device, self.dev[device])
|
||||
try:
|
||||
self.regex = re.compile(filter_text, re.IGNORECASE)
|
||||
except re.error:
|
||||
self.regex = None # Invalid regex, disable filtering
|
||||
for device in self.dev:
|
||||
self._device_items[device].setHidden(False)
|
||||
self.dev_list.unhide_all()
|
||||
return
|
||||
for device in self.dev:
|
||||
self._device_items[device].setHidden(not self.regex.search(device))
|
||||
self.dev_list.set_hidden(filter(lambda d: not self.regex.search(d), self.dev.keys()))
|
||||
|
||||
@SafeSlot()
|
||||
def _load_from_file(self):
|
||||
|
||||
@@ -1,93 +1,90 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>406</width>
|
||||
<height>500</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="browser_group_box">
|
||||
<property name="title">
|
||||
<string>Device Browser</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="filter_layout">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="filter_input">
|
||||
<property name="placeholderText">
|
||||
<string>Filter</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="button_box">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QToolButton" name="add_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="save_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="import_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="scan_running_warning">
|
||||
<property name="styleSheet">
|
||||
<string notr="true"/>
|
||||
<class>Form</class>
|
||||
<widget class="QWidget" name="Form">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>406</width>
|
||||
<height>500</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>warning</string>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QListWidget" name="device_list"/>
|
||||
</item>
|
||||
</layout>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="browser_group_box">
|
||||
<property name="title">
|
||||
<string>Device Browser</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="filter_layout">
|
||||
<item>
|
||||
<widget class="QLineEdit" name="filter_input">
|
||||
<property name="placeholderText">
|
||||
<string>Filter</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="button_box">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="leftMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QToolButton" name="add_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="save_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="import_button">
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="scan_running_warning">
|
||||
<property name="styleSheet">
|
||||
<string notr="true" />
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>warning</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
<resources />
|
||||
<connections />
|
||||
</ui>
|
||||
@@ -35,9 +35,6 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
class DeviceItem(ExpandableGroupFrame):
|
||||
broadcast_size_hint = Signal(QSize)
|
||||
imminent_deletion = Signal()
|
||||
|
||||
RPC = False
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -5,11 +5,13 @@ from typing import TYPE_CHECKING
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.messages import ScanHistoryMessage
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget, ConnectionConfig
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.client import BECClient
|
||||
@@ -25,22 +27,38 @@ class BECHistoryManager(QtCore.QObject):
|
||||
|
||||
# ScanHistoryMessage.model_dump() (dict)
|
||||
scan_history_updated = QtCore.Signal(dict)
|
||||
scan_history_refreshed = QtCore.Signal(list)
|
||||
|
||||
def __init__(self, parent, client: BECClient):
|
||||
super().__init__(parent)
|
||||
self._load_attempt = 0
|
||||
self.client = client
|
||||
self._cb_id = self.client.callbacks.register(
|
||||
event_type=EventType.SCAN_HISTORY_UPDATE, callback=self._on_scan_history_update
|
||||
self._cb_id: dict[str, int] = {}
|
||||
self._cb_id["update_scan_history"] = self.client.callbacks.register(
|
||||
EventType.SCAN_HISTORY_UPDATE, self._on_scan_history_update
|
||||
)
|
||||
self._cb_id["scan_history_loaded"] = self.client.callbacks.register(
|
||||
EventType.SCAN_HISTORY_LOADED, self._on_scan_history_reloaded
|
||||
)
|
||||
|
||||
def refresh_scan_history(self) -> None:
|
||||
"""Refresh the scan history from the client."""
|
||||
all_messages = []
|
||||
# pylint: disable=protected-access
|
||||
for scan_id in self.client.history._scan_ids: # pylint: disable=protected-access
|
||||
history_msg = self.client.history._scan_data.get(scan_id, None)
|
||||
if history_msg is None:
|
||||
logger.info(f"Scan history message for scan_id {scan_id} not found.")
|
||||
continue
|
||||
self.scan_history_updated.emit(history_msg.model_dump())
|
||||
all_messages.append(history_msg.model_dump())
|
||||
self.scan_history_refreshed.emit(all_messages)
|
||||
|
||||
def _on_scan_history_reloaded(self, history_msgs: list[ScanHistoryMessage]) -> None:
|
||||
"""Handle scan history reloaded event from the client."""
|
||||
if not history_msgs:
|
||||
logger.warning("Scan history reloaded with no messages.")
|
||||
return
|
||||
self.scan_history_refreshed.emit([msg.model_dump() for msg in history_msgs])
|
||||
|
||||
def _on_scan_history_update(self, history_msg: ScanHistoryMessage) -> None:
|
||||
"""Handle scan history updates from the client."""
|
||||
@@ -48,8 +66,10 @@ class BECHistoryManager(QtCore.QObject):
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up the manager by disconnecting callbacks."""
|
||||
self.client.callbacks.remove(self._cb_id)
|
||||
for cb_id in self._cb_id.values():
|
||||
self.client.callbacks.remove(cb_id)
|
||||
self.scan_history_updated.disconnect()
|
||||
self.scan_history_refreshed.disconnect()
|
||||
|
||||
|
||||
class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
@@ -80,15 +100,10 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
theme_update=theme_update,
|
||||
**kwargs,
|
||||
)
|
||||
colors = get_accent_colors()
|
||||
self.status_colors = {
|
||||
"closed": colors.success,
|
||||
"halted": colors.warning,
|
||||
"aborted": colors.emergency,
|
||||
}
|
||||
# self.status_colors = {"closed": "#00e676", "halted": "#ffca28", "aborted": "#ff5252"}
|
||||
self.status_icons = self._create_status_icons()
|
||||
self.column_header = ["Scan Nr", "Scan Name", "Status"]
|
||||
self.scan_history: list[ScanHistoryMessage] = [] # newest at index 0
|
||||
self.scan_history_ids: set[str] = set() # scan IDs of the scan history
|
||||
self.max_length = max_length # Maximum number of scan history entries to keep
|
||||
self.bec_scan_history_manager = BECHistoryManager(parent=self, client=self.client)
|
||||
self._set_policies()
|
||||
@@ -97,6 +112,12 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
header = self.header()
|
||||
header.setToolTip(f"Last {self.max_length} scans in history.")
|
||||
self.bec_scan_history_manager.scan_history_updated.connect(self.update_history)
|
||||
self.bec_scan_history_manager.scan_history_refreshed.connect(self.update_full_history)
|
||||
self._container = QtWidgets.QStackedLayout()
|
||||
self._container.setStackingMode(QtWidgets.QStackedLayout.StackAll)
|
||||
self.setLayout(self._container)
|
||||
self._add_overlay()
|
||||
self._start_waiting_display()
|
||||
self.refresh()
|
||||
|
||||
def _set_policies(self):
|
||||
@@ -117,16 +138,52 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
for column in range(1, self.columnCount()):
|
||||
header.setSectionResizeMode(column, QtWidgets.QHeaderView.ResizeMode.Stretch)
|
||||
|
||||
def _create_status_icons(self) -> dict[str, QtGui.QIcon]:
|
||||
"""Create status icons for the scan history."""
|
||||
colors = get_accent_colors()
|
||||
return {
|
||||
"closed": material_icon(
|
||||
icon_name="fiber_manual_record", filled=True, color=colors.success
|
||||
),
|
||||
"halted": material_icon(
|
||||
icon_name="fiber_manual_record", filled=True, color=colors.warning
|
||||
),
|
||||
"aborted": material_icon(
|
||||
icon_name="fiber_manual_record", filled=True, color=colors.emergency
|
||||
),
|
||||
"unknown": material_icon(
|
||||
icon_name="fiber_manual_record", filled=True, color=QtGui.QColor("#b0bec5")
|
||||
),
|
||||
}
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
"""Apply the theme to the widget."""
|
||||
colors = get_accent_colors()
|
||||
self.status_colors = {
|
||||
"closed": colors.success,
|
||||
"halted": colors.warning,
|
||||
"aborted": colors.emergency,
|
||||
}
|
||||
self.status_icons = self._create_status_icons()
|
||||
self.repaint()
|
||||
|
||||
def _add_overlay(self):
|
||||
self._overlay_widget = QtWidgets.QWidget()
|
||||
self._overlay_widget.setStyleSheet("background-color: rgba(240, 240, 240, 180);")
|
||||
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._spinner = SpinnerWidget(parent=self)
|
||||
self._spinner.setFixedSize(QtCore.QSize(32, 32))
|
||||
self._overlay_layout.addWidget(self._spinner)
|
||||
self._container.addWidget(self._overlay_widget)
|
||||
|
||||
def _start_waiting_display(self):
|
||||
self._overlay_widget.setVisible(True)
|
||||
self._spinner.start()
|
||||
QtWidgets.QApplication.processEvents()
|
||||
|
||||
def _stop_waiting_display(self):
|
||||
self._overlay_widget.setVisible(False)
|
||||
self._spinner.stop()
|
||||
QtWidgets.QApplication.processEvents()
|
||||
|
||||
def _current_item_changed(
|
||||
self, current: QtWidgets.QTreeWidgetItem, previous: QtWidgets.QTreeWidgetItem
|
||||
):
|
||||
@@ -145,9 +202,14 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
@SafeSlot()
|
||||
def refresh(self):
|
||||
"""Refresh the scan history view."""
|
||||
while len(self.scan_history) > 0:
|
||||
self.remove_scan(index=0)
|
||||
self.bec_scan_history_manager.refresh_scan_history()
|
||||
# pylint: disable=protected-access
|
||||
if self.client.history._scan_history_loaded_event.is_set():
|
||||
while len(self.scan_history) > 0:
|
||||
self.remove_scan(index=0)
|
||||
self.bec_scan_history_manager.refresh_scan_history()
|
||||
return
|
||||
else:
|
||||
logger.info("Scan history not loaded yet, waiting for it to be loaded.")
|
||||
|
||||
@SafeSlot(dict)
|
||||
def update_history(self, msg_dump: dict):
|
||||
@@ -156,6 +218,20 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
self.add_scan(msg)
|
||||
self.ensure_history_max_length()
|
||||
|
||||
@SafeSlot(list)
|
||||
def update_full_history(self, all_messages: list[dict]):
|
||||
"""Update the scan history with a full list of scan data."""
|
||||
messages = []
|
||||
for msg_dump in all_messages:
|
||||
msg = ScanHistoryMessage(**msg_dump)
|
||||
messages.append(msg)
|
||||
if len(messages) >= self.max_length:
|
||||
messages.pop(0)
|
||||
messages.sort(key=lambda m: m.scan_number, reverse=False)
|
||||
self.add_scans(messages)
|
||||
self.ensure_history_max_length()
|
||||
self._stop_waiting_display()
|
||||
|
||||
def ensure_history_max_length(self) -> None:
|
||||
"""
|
||||
Method to ensure the scan history does not exceed the maximum length.
|
||||
@@ -172,6 +248,34 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
"""
|
||||
Add a scan entry to the tree widget.
|
||||
|
||||
Args:
|
||||
msg (ScanHistoryMessage): The scan history message containing scan details.
|
||||
"""
|
||||
self._add_scan_to_scan_history(msg)
|
||||
tree_item = self._setup_tree_item(msg)
|
||||
self.insertTopLevelItem(0, tree_item)
|
||||
|
||||
def _setup_tree_item(self, msg: ScanHistoryMessage) -> QtWidgets.QTreeWidgetItem:
|
||||
"""Setup a tree item for the scan history message.
|
||||
|
||||
Args:
|
||||
msg (ScanHistoryMessage): The scan history message containing scan details.
|
||||
|
||||
Returns:
|
||||
QtWidgets.QTreeWidgetItem: The tree item representing the scan history message.
|
||||
"""
|
||||
tree_item = QtWidgets.QTreeWidgetItem([str(msg.scan_number), msg.scan_name, ""])
|
||||
icon = self.status_icons.get(msg.exit_status, self.status_icons["unknown"])
|
||||
tree_item.setIcon(2, icon)
|
||||
tree_item.setExpanded(False)
|
||||
for col in range(tree_item.columnCount()):
|
||||
tree_item.setToolTip(col, f"Status: {msg.exit_status}")
|
||||
return tree_item
|
||||
|
||||
def _add_scan_to_scan_history(self, msg: ScanHistoryMessage):
|
||||
"""
|
||||
Add a scan message to the internal scan history list and update the tree widget.
|
||||
|
||||
Args:
|
||||
msg (ScanHistoryMessage): The scan history message containing scan details.
|
||||
"""
|
||||
@@ -180,25 +284,25 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
f"Old scan history entry fo scan {msg.scan_id} without stored_data_info, skipping."
|
||||
)
|
||||
return
|
||||
if msg in self.scan_history:
|
||||
if msg.scan_id in self.scan_history_ids:
|
||||
logger.info(f"Scan {msg.scan_id} already in history, skipping.")
|
||||
return
|
||||
self.scan_history.insert(0, msg)
|
||||
tree_item = QtWidgets.QTreeWidgetItem([str(msg.scan_number), msg.scan_name, ""])
|
||||
color = QtGui.QColor(self.status_colors.get(msg.exit_status, "#b0bec5"))
|
||||
pix = QtGui.QPixmap(10, 10)
|
||||
pix.fill(QtCore.Qt.transparent)
|
||||
with QtGui.QPainter(pix) as p:
|
||||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
p.setPen(QtCore.Qt.NoPen)
|
||||
p.setBrush(color)
|
||||
p.drawEllipse(0, 0, 10, 10)
|
||||
tree_item.setIcon(2, QtGui.QIcon(pix))
|
||||
tree_item.setForeground(2, QtGui.QBrush(color))
|
||||
for col in range(tree_item.columnCount()):
|
||||
tree_item.setToolTip(col, f"Status: {msg.exit_status}")
|
||||
self.insertTopLevelItem(0, tree_item)
|
||||
tree_item.setExpanded(False)
|
||||
self.scan_history_ids.add(msg.scan_id)
|
||||
|
||||
def add_scans(self, messages: list[ScanHistoryMessage]):
|
||||
"""
|
||||
Add multiple scan entries to the tree widget.
|
||||
|
||||
Args:
|
||||
messages (list[ScanHistoryMessage]): List of scan history messages containing scan details.
|
||||
"""
|
||||
tree_items = []
|
||||
for msg in messages:
|
||||
self._add_scan_to_scan_history(msg)
|
||||
tree_items.append(self._setup_tree_item(msg))
|
||||
# Insert for insertTopLevelItems needs to reversed to keep order of scan_history list
|
||||
self.insertTopLevelItems(0, tree_items[::-1])
|
||||
|
||||
def remove_scan(self, index: int):
|
||||
"""
|
||||
@@ -212,6 +316,7 @@ class ScanHistoryView(BECWidget, QtWidgets.QTreeWidget):
|
||||
index = len(self.scan_history) + index
|
||||
try:
|
||||
msg = self.scan_history.pop(index)
|
||||
self.scan_history_ids.remove(msg.scan_id)
|
||||
self.no_scan_selected.emit()
|
||||
except IndexError:
|
||||
logger.warning(f"Invalid index {index} for removing scan entry from history.")
|
||||
|
||||
146
bec_widgets/widgets/utility/ide_explorer/ide_explorer.py
Normal file
146
bec_widgets/widgets/utility/ide_explorer/ide_explorer.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import datetime
|
||||
import importlib
|
||||
import os
|
||||
|
||||
from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
|
||||
from bec_widgets.widgets.containers.explorer.explorer import Explorer
|
||||
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
|
||||
|
||||
|
||||
class IDEExplorer(BECWidget, QWidget):
|
||||
"""Integrated Development Environment Explorer"""
|
||||
|
||||
PLUGIN = True
|
||||
RPC = False
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._sections = set()
|
||||
self.main_explorer = Explorer(parent=self)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.main_explorer)
|
||||
self.setLayout(layout)
|
||||
self.sections = ["scripts"]
|
||||
|
||||
@SafeProperty(list)
|
||||
def sections(self):
|
||||
return list(self._sections)
|
||||
|
||||
@sections.setter
|
||||
def sections(self, value):
|
||||
existing_sections = set(self._sections)
|
||||
self._sections = set(value)
|
||||
self._update_section_visibility(self._sections - existing_sections)
|
||||
|
||||
def _update_section_visibility(self, sections):
|
||||
for section in sections:
|
||||
self._add_section(section)
|
||||
|
||||
def _add_section(self, section_name):
|
||||
match section_name.lower():
|
||||
case "scripts":
|
||||
self.add_script_section()
|
||||
case _:
|
||||
pass
|
||||
|
||||
def add_script_section(self):
|
||||
section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0)
|
||||
section.expanded = False
|
||||
|
||||
script_explorer = Explorer(parent=self)
|
||||
script_widget = ScriptTreeWidget(parent=self)
|
||||
local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
|
||||
local_scripts_section.header_add_button.clicked.connect(self._add_local_script)
|
||||
local_scripts_section.set_widget(script_widget)
|
||||
local_script_dir = self.client._service_config.model.user_scripts.base_path
|
||||
if not os.path.exists(local_script_dir):
|
||||
os.makedirs(local_script_dir)
|
||||
script_widget.set_directory(local_script_dir)
|
||||
script_explorer.add_section(local_scripts_section)
|
||||
|
||||
section.set_widget(script_explorer)
|
||||
self.main_explorer.add_section(section)
|
||||
|
||||
plugin_scripts_dir = None
|
||||
plugins = importlib.metadata.entry_points(group="bec")
|
||||
for plugin in plugins:
|
||||
if plugin.name == "plugin_bec":
|
||||
plugin = plugin.load()
|
||||
plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts")
|
||||
break
|
||||
|
||||
if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir):
|
||||
return
|
||||
shared_script_section = CollapsibleSection(title="Shared", parent=self)
|
||||
shared_script_widget = ScriptTreeWidget(parent=self)
|
||||
shared_script_section.set_widget(shared_script_widget)
|
||||
shared_script_widget.set_directory(plugin_scripts_dir)
|
||||
script_explorer.add_section(shared_script_section)
|
||||
# macros_section = CollapsibleSection("MACROS", indentation=0)
|
||||
# macros_section.set_widget(QLabel("Macros will be implemented later"))
|
||||
# self.main_explorer.add_section(macros_section)
|
||||
|
||||
def _add_local_script(self):
|
||||
"""Show a dialog to enter the name of a new script and create it."""
|
||||
|
||||
target_section = self.main_explorer.get_section("SCRIPTS")
|
||||
script_dir_section = target_section.content_widget.get_section("Local")
|
||||
|
||||
local_script_dir = script_dir_section.content_widget.directory
|
||||
|
||||
# Prompt user for filename
|
||||
filename, ok = QInputDialog.getText(
|
||||
self, "New Script", f"Enter script name ({local_script_dir}/<filename>):"
|
||||
)
|
||||
|
||||
if not ok or not filename:
|
||||
return # User cancelled or didn't enter a name
|
||||
|
||||
# Add .py extension if not already present
|
||||
if not filename.endswith(".py"):
|
||||
filename = f"{filename}.py"
|
||||
|
||||
file_path = os.path.join(local_script_dir, filename)
|
||||
|
||||
# Check if file already exists
|
||||
if os.path.exists(file_path):
|
||||
response = QMessageBox.question(
|
||||
self,
|
||||
"File exists",
|
||||
f"The file '{filename}' already exists. Do you want to overwrite it?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
if response != QMessageBox.StandardButton.Yes:
|
||||
return # User chose not to overwrite
|
||||
|
||||
try:
|
||||
# Create the file with a basic template
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"""
|
||||
\"\"\"
|
||||
{filename} - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
\"\"\"
|
||||
"""
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Show error if file creation failed
|
||||
QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
script_explorer = IDEExplorer()
|
||||
script_explorer.show()
|
||||
app.exec_()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['ide_explorer.py']}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='IDEExplorer' name='ide_explorer'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class IDEExplorerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = IDEExplorer(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(IDEExplorer.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "ide_explorer"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "IDEExplorer"
|
||||
|
||||
def toolTip(self):
|
||||
return "Integrated Development Environment Explorer"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer_plugin import IDEExplorerPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(IDEExplorerPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -2,11 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Sequence
|
||||
|
||||
import numpy as np
|
||||
from bec_lib.device import Device, Signal
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtCore import Signal as QSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -20,17 +22,10 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
DeviceInputConfig,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
DeviceSignalInputBaseConfig,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
@@ -48,8 +43,9 @@ class ChoiceDialog(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: ConnectionConfig | None = None,
|
||||
client: BECClient | None = None,
|
||||
device: str | None = None,
|
||||
signal: str | None = None,
|
||||
show_hinted: bool = True,
|
||||
show_normal: bool = False,
|
||||
show_config: bool = False,
|
||||
@@ -63,18 +59,8 @@ class ChoiceDialog(QDialog):
|
||||
|
||||
layout = QHBoxLayout()
|
||||
|
||||
config_dict = config.model_dump() if config is not None else {}
|
||||
self._device_config = DeviceInputConfig.model_validate(config_dict)
|
||||
self._signal_config = DeviceSignalInputBaseConfig.model_validate(config_dict)
|
||||
self._device_field = DeviceLineEdit(
|
||||
config=self._device_config, parent=parent, client=client
|
||||
)
|
||||
self._signal_field = SignalComboBox(
|
||||
config=self._signal_config,
|
||||
device=self._signal_config.device,
|
||||
parent=parent,
|
||||
client=client,
|
||||
)
|
||||
self._device_field = DeviceLineEdit(parent=parent, client=client)
|
||||
self._signal_field = SignalComboBox(parent=parent, client=client)
|
||||
layout.addWidget(self._device_field)
|
||||
layout.addWidget(self._signal_field)
|
||||
|
||||
@@ -89,7 +75,10 @@ class ChoiceDialog(QDialog):
|
||||
|
||||
self.setLayout(layout)
|
||||
self._device_field.textChanged.connect(self._update_device)
|
||||
self._device_field.setText(config.device if config is not None else "")
|
||||
if device:
|
||||
self._device_field.set_device(device)
|
||||
if signal and signal in set(s[0] for s in self._signal_field.signals):
|
||||
self._signal_field.set_signal(signal)
|
||||
|
||||
def _display_error(self):
|
||||
try:
|
||||
@@ -123,11 +112,19 @@ class ChoiceDialog(QDialog):
|
||||
self.accepted_output.emit(
|
||||
self._device_field.text(), self._signal_field.selected_signal_comp_name
|
||||
)
|
||||
self.cleanup()
|
||||
return super().accept()
|
||||
|
||||
def reject(self):
|
||||
self.cleanup()
|
||||
return super().reject()
|
||||
|
||||
def cleanup(self):
|
||||
self._device_field.close()
|
||||
self._signal_field.close()
|
||||
|
||||
|
||||
class SignalLabel(BECWidget, QWidget):
|
||||
|
||||
ICON_NAME = "scoreboard"
|
||||
RPC = True
|
||||
PLUGIN = True
|
||||
@@ -151,6 +148,8 @@ class SignalLabel(BECWidget, QWidget):
|
||||
"show_config_signals.setter",
|
||||
"display_array_data",
|
||||
"display_array_data.setter",
|
||||
"max_list_display_len",
|
||||
"max_list_display_len.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -178,7 +177,6 @@ class SignalLabel(BECWidget, QWidget):
|
||||
custom_label (str, optional): Custom label for the widget. Defaults to "".
|
||||
custom_units (str, optional): Custom units for the widget. Defaults to "".
|
||||
"""
|
||||
self._config = DeviceSignalInputBaseConfig(default=signal, device=device)
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
|
||||
self._device = device
|
||||
@@ -189,6 +187,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._show_default_units: bool = show_default_units
|
||||
self._decimal_places = 3
|
||||
self._dtype = None
|
||||
self._max_list_display_len = 5
|
||||
|
||||
self._show_hinted_signals: bool = True
|
||||
self._show_normal_signals: bool = True
|
||||
@@ -227,9 +226,10 @@ class SignalLabel(BECWidget, QWidget):
|
||||
|
||||
def _create_dialog(self):
|
||||
return ChoiceDialog(
|
||||
config=self._config,
|
||||
parent=self,
|
||||
client=self.client,
|
||||
device=self.device,
|
||||
signal=self._signal_key,
|
||||
show_config=self.show_config_signals,
|
||||
show_normal=self.show_normal_signals,
|
||||
show_hinted=self.show_hinted_signals,
|
||||
@@ -280,7 +280,7 @@ class SignalLabel(BECWidget, QWidget):
|
||||
return
|
||||
self._value = value
|
||||
self._units = self._signal_info.get("egu", "")
|
||||
self._dtype = self._signal_info.get("dtype", "float")
|
||||
self._dtype = self._signal_info.get("dtype")
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg: dict, metadata: dict) -> None:
|
||||
@@ -305,11 +305,13 @@ class SignalLabel(BECWidget, QWidget):
|
||||
except KeyError:
|
||||
return "", {}
|
||||
if signal_info["kind_str"] == Kind.hinted.name:
|
||||
return signal_info["obj_name"], signal_info
|
||||
return signal_info["obj_name"], signal_info.get("describe", {})
|
||||
else:
|
||||
return f"{self._device}_{self._signal}", signal_info
|
||||
return f"{self._device}_{self._signal}", signal_info.get("describe", {})
|
||||
elif isinstance(self._device_obj, Signal):
|
||||
return self._device, self._device_obj._info["describe_configuration"]
|
||||
info = self._device_obj._info["describe_configuration"][self._device]
|
||||
info["egu"] = self._device_obj._info["describe_configuration"].get("egu")
|
||||
return (self._device, info)
|
||||
return "", {}
|
||||
|
||||
@SafeProperty(str)
|
||||
@@ -322,7 +324,6 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self.disconnect_device()
|
||||
self._device = value
|
||||
self._device_obj = self.dev.get(self._device)
|
||||
self._config.device = value
|
||||
self.connect_device()
|
||||
self._update_label()
|
||||
|
||||
@@ -335,7 +336,6 @@ class SignalLabel(BECWidget, QWidget):
|
||||
def signal(self, value: str) -> None:
|
||||
self.disconnect_device()
|
||||
self._signal = value
|
||||
self._config.default = value
|
||||
self.connect_device()
|
||||
self._update_label()
|
||||
|
||||
@@ -369,6 +369,16 @@ class SignalLabel(BECWidget, QWidget):
|
||||
self._custom_label = value
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(str)
|
||||
def max_list_display_len(self) -> int:
|
||||
"""For small lists, the max length to display"""
|
||||
return self._max_list_display_len
|
||||
|
||||
@max_list_display_len.setter
|
||||
def max_list_display_len(self, value: int) -> None:
|
||||
self._max_list_display_len = value
|
||||
self.set_display_value(self._value)
|
||||
|
||||
@SafeProperty(str)
|
||||
def custom_units(self) -> str:
|
||||
"""Use a custom unit string"""
|
||||
@@ -429,6 +439,11 @@ class SignalLabel(BECWidget, QWidget):
|
||||
def _format_value(self, value: Any):
|
||||
if self._dtype == "array" and not self.display_array_data:
|
||||
return "ARRAY DATA"
|
||||
if not isinstance(value, str) and isinstance(value, (Sequence, np.ndarray)):
|
||||
if len(value) < self._max_list_display_len:
|
||||
return str(value)
|
||||
else:
|
||||
return "ARRAY DATA"
|
||||
if self._decimal_places == 0:
|
||||
return value
|
||||
try:
|
||||
@@ -468,7 +483,6 @@ class SignalLabel(BECWidget, QWidget):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = QWidget()
|
||||
w.setLayout(QVBoxLayout())
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.33.0"
|
||||
version = "2.38.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -20,10 +20,11 @@ dependencies = [
|
||||
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
"PySide6~=6.8.2",
|
||||
"PySide6==6.9.0",
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
"qtmonaco~=0.5",
|
||||
"PySide6-QtAds==4.4.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -39,6 +40,9 @@ dev = [
|
||||
"pytest-xvfb~=3.0",
|
||||
"pytest~=8.0",
|
||||
"pytest-cov~=6.1.1",
|
||||
"watchdog~=6.0",
|
||||
"pre_commit~=4.2",
|
||||
"thefuzz~=0.22",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
1044
tests/unit_tests/test_advanced_dock_area.py
Normal file
1044
tests/unit_tests/test_advanced_dock_area.py
Normal file
File diff suppressed because it is too large
Load Diff
78
tests/unit_tests/test_available_device_resources.py
Normal file
78
tests/unit_tests/test_available_device_resources.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from copy import copy
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import (
|
||||
HashableDevice,
|
||||
_HashableDeviceSet,
|
||||
)
|
||||
|
||||
TEST_DEVICE_DICT = {
|
||||
"name": "test_device",
|
||||
"deviceClass": "TestDeviceClass",
|
||||
"readoutPriority": "baseline",
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
|
||||
def _test_device_dict(**kwargs):
|
||||
new = copy(TEST_DEVICE_DICT)
|
||||
new.update(kwargs)
|
||||
return new
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs_1, kwargs_2, kwargs_3, kwargs_4, n",
|
||||
[
|
||||
({}, {}, {}, {}, 1),
|
||||
({}, {}, {}, {"deviceConfig": {"a": 1}}, 1),
|
||||
({}, {}, {}, {"name": "test_device_2"}, 2),
|
||||
({}, {}, {"name": "test_device_2"}, {"deviceClass": "OtherDeviceClass"}, 3),
|
||||
],
|
||||
)
|
||||
def test_hashable_device_set_merges_equal(kwargs_1, kwargs_2, kwargs_3, kwargs_4, n):
|
||||
item_1 = HashableDevice(**_test_device_dict(**kwargs_1))
|
||||
item_2 = HashableDevice(**_test_device_dict(**kwargs_2))
|
||||
item_3 = HashableDevice(**_test_device_dict(**kwargs_3))
|
||||
item_4 = HashableDevice(**_test_device_dict(**kwargs_4))
|
||||
|
||||
test_set = _HashableDeviceSet((item_1, item_2, item_3, item_4))
|
||||
assert len(test_set) == n
|
||||
|
||||
|
||||
def test_hashable_device_set_or_adds_sources():
|
||||
item_1 = HashableDevice(**_test_device_dict(), source_files={"a", "b"})
|
||||
item_2 = HashableDevice(**_test_device_dict(), source_files={"c", "d"})
|
||||
|
||||
set_1 = _HashableDeviceSet((item_1,))
|
||||
set_2 = _HashableDeviceSet((item_2,))
|
||||
|
||||
combined = set_1 | set_2
|
||||
assert len(combined) == 1
|
||||
assert combined.pop().source_files == {"a", "b", "c", "d"}
|
||||
|
||||
|
||||
def test_hashable_device_set_or_adds_tags():
|
||||
item_1 = HashableDevice(
|
||||
**_test_device_dict(deviceTags={"tag1"}, deviceConfig={"param": "value"}),
|
||||
source_files={"a", "b"},
|
||||
)
|
||||
item_2 = HashableDevice(
|
||||
**_test_device_dict(deviceTags={"tag2"}, deviceConfig={"param": "value"}),
|
||||
source_files={"c", "d"},
|
||||
)
|
||||
item_3 = HashableDevice(
|
||||
**_test_device_dict(deviceTags={"tag3"}, deviceConfig={"param": "other_value"}),
|
||||
source_files={"q"},
|
||||
)
|
||||
|
||||
set_1 = _HashableDeviceSet((item_1,))
|
||||
set_2 = _HashableDeviceSet((item_2,))
|
||||
set_3 = _HashableDeviceSet((item_3,))
|
||||
|
||||
combined = sorted(set_1 | set_2 | set_3, key=lambda hd: hd.deviceConfig["param"])
|
||||
assert len(combined) == 2
|
||||
assert combined[0].source_files == {"q"}
|
||||
assert combined[0].deviceTags == {"tag3"}
|
||||
assert combined[1].source_files == {"a", "b", "c", "d"}
|
||||
assert combined[1].deviceTags == {"tag1", "tag2"}
|
||||
@@ -67,7 +67,7 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher):
|
||||
mixin._client = bec_dispatcher.client
|
||||
mixin._gui_id = "gui_id"
|
||||
mixin._gui_is_alive = mock.MagicMock()
|
||||
mixin._gui_is_alive.side_effect = [True]
|
||||
mixin._gui_is_alive.side_effect = [False, False, True]
|
||||
|
||||
try:
|
||||
yield mixin
|
||||
|
||||
@@ -37,11 +37,11 @@ def device_browser(qtbot, mocked_client):
|
||||
yield dev_browser
|
||||
|
||||
|
||||
def test_device_browser_init_with_devices(device_browser):
|
||||
def test_device_browser_init_with_devices(device_browser: DeviceBrowser):
|
||||
"""
|
||||
Test that the device browser is initialized with the correct number of devices.
|
||||
"""
|
||||
device_list = device_browser.ui.device_list
|
||||
device_list = device_browser.dev_list
|
||||
assert device_list.count() == len(device_browser.dev)
|
||||
|
||||
|
||||
@@ -58,11 +58,11 @@ def test_device_browser_filtering(
|
||||
expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev)
|
||||
|
||||
def num_visible(item_dict):
|
||||
return len(list(filter(lambda i: not i.isHidden(), item_dict.values())))
|
||||
return len(list(filter(lambda i: not i.widget.isHidden(), item_dict.values())))
|
||||
|
||||
device_browser.ui.filter_input.setText(search_term)
|
||||
qtbot.wait(100)
|
||||
assert num_visible(device_browser._device_items) == expected
|
||||
assert num_visible(device_browser.dev_list._item_dict) == expected
|
||||
|
||||
|
||||
def test_device_item_mouse_press_event(device_browser, qtbot):
|
||||
@@ -70,8 +70,8 @@ def test_device_item_mouse_press_event(device_browser, qtbot):
|
||||
Test that the mousePressEvent is triggered correctly.
|
||||
"""
|
||||
# Simulate a left mouse press event on the device item
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
|
||||
qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton)
|
||||
|
||||
|
||||
@@ -88,8 +88,8 @@ def test_device_item_expansion(device_browser, qtbot):
|
||||
Test that the form is displayed when the item is expanded, and that the expansion is triggered
|
||||
by clicking on the expansion button, the title, or the device icon
|
||||
"""
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
|
||||
qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton)
|
||||
tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget()
|
||||
qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100)
|
||||
@@ -115,8 +115,8 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt
|
||||
"""
|
||||
Test that the mousePressEvent is triggered correctly and initiates a drag.
|
||||
"""
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
|
||||
device_name = widget.device
|
||||
with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec:
|
||||
with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata:
|
||||
@@ -133,19 +133,19 @@ def test_device_item_double_click_event(device_browser, qtbot):
|
||||
Test that the mouseDoubleClickEvent is triggered correctly.
|
||||
"""
|
||||
# Simulate a left mouse press event on the device item
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
|
||||
qtbot.mouseDClick(widget, Qt.LeftButton)
|
||||
|
||||
|
||||
def test_device_deletion(device_browser, qtbot):
|
||||
device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item)
|
||||
device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0)
|
||||
widget: DeviceItem = device_browser.dev_list.itemWidget(device_item)
|
||||
widget._config_helper = mock.MagicMock()
|
||||
|
||||
assert widget.device in device_browser._device_items
|
||||
assert widget.device in device_browser.dev_list._item_dict
|
||||
qtbot.mouseClick(widget.delete_button, Qt.LeftButton)
|
||||
qtbot.waitUntil(lambda: widget.device not in device_browser._device_items, timeout=10000)
|
||||
qtbot.waitUntil(lambda: widget.device not in device_browser.dev_list._item_dict, timeout=10000)
|
||||
|
||||
|
||||
def test_signal_display(mocked_client, qtbot):
|
||||
|
||||
55
tests/unit_tests/test_explorer.py
Normal file
55
tests/unit_tests/test_explorer.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
|
||||
from bec_widgets.widgets.containers.explorer.explorer import Explorer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def explorer(qtbot):
|
||||
widget = Explorer()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_explorer_initialization(explorer):
|
||||
assert explorer is not None
|
||||
assert len(explorer.sections) == 0
|
||||
|
||||
|
||||
def test_add_remove_section(explorer, qtbot):
|
||||
section = CollapsibleSection(title="Test Section", parent=explorer)
|
||||
explorer.add_section(section)
|
||||
assert len(explorer.sections) == 1
|
||||
assert explorer.sections[0].title == "Test Section"
|
||||
|
||||
section2 = CollapsibleSection(title="Another Section", parent=explorer)
|
||||
explorer.add_section(section2)
|
||||
assert len(explorer.sections) == 2
|
||||
assert explorer.sections[1].title == "Another Section"
|
||||
|
||||
explorer.remove_section(section)
|
||||
assert len(explorer.sections) == 1
|
||||
assert explorer.sections[0].title == "Another Section"
|
||||
qtbot.wait(100) # Allow time for the section to be removed
|
||||
assert explorer.splitter.count() == 1
|
||||
|
||||
|
||||
def test_section_reorder(explorer):
|
||||
section = CollapsibleSection(title="Section 1", parent=explorer)
|
||||
explorer.add_section(section)
|
||||
|
||||
section2 = CollapsibleSection(title="Section 2", parent=explorer)
|
||||
explorer.add_section(section2)
|
||||
|
||||
assert explorer.sections[0].title == "Section 1"
|
||||
assert explorer.sections[1].title == "Section 2"
|
||||
assert len(explorer.sections) == 2
|
||||
assert explorer.splitter.count() == 2
|
||||
|
||||
explorer._handle_section_reorder("Section 1", "Section 2")
|
||||
|
||||
assert explorer.sections[0].title == "Section 2"
|
||||
assert explorer.sections[1].title == "Section 1"
|
||||
assert len(explorer.sections) == 2
|
||||
assert explorer.splitter.count() == 2
|
||||
36
tests/unit_tests/test_ide_explorer.py
Normal file
36
tests/unit_tests/test_ide_explorer.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ide_explorer(qtbot, tmpdir):
|
||||
"""Create an IDEExplorer widget for testing"""
|
||||
widget = IDEExplorer()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_ide_explorer_initialization(ide_explorer):
|
||||
"""Test the initialization of the IDEExplorer widget"""
|
||||
assert ide_explorer is not None
|
||||
assert "scripts" in ide_explorer.sections
|
||||
assert ide_explorer.main_explorer.sections[0].title == "SCRIPTS"
|
||||
|
||||
|
||||
def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir):
|
||||
local_script_section = ide_explorer.main_explorer.get_section(
|
||||
"SCRIPTS"
|
||||
).content_widget.get_section("Local")
|
||||
local_script_section.content_widget.set_directory(str(tmpdir))
|
||||
|
||||
with mock.patch(
|
||||
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
|
||||
return_value=("test_file.py", True),
|
||||
):
|
||||
ide_explorer._add_local_script()
|
||||
assert os.path.exists(os.path.join(tmpdir, "test_file.py"))
|
||||
255
tests/unit_tests/test_plugin_creator.py
Normal file
255
tests/unit_tests/test_plugin_creator.py
Normal file
@@ -0,0 +1,255 @@
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import copier
|
||||
import pytest
|
||||
from bec_lib.utils.plugin_manager import main
|
||||
from bec_lib.utils.plugin_manager._util import _goto_dir
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from bec_widgets.utils.bec_plugin_manager.create.widget import _commit_added_widget, _widget_exists
|
||||
|
||||
PLUGIN_REPO = "https://github.com/bec-project/plugin_copier_template.git"
|
||||
|
||||
REPLACEMENT_UI_CONTENTS = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>testWidget6</class>
|
||||
<widget class="QWidget" name="testWidget6">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>539</width>
|
||||
<height>287</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="Waveform" name="waveform">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>30</x>
|
||||
<y>0</y>
|
||||
<width>361</width>
|
||||
<height>125</height>
|
||||
</rect>
|
||||
</property>
|
||||
</widget>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
<class>Waveform</class>
|
||||
<extends>QWidget</extends>
|
||||
<header>waveform</header>
|
||||
</customwidget>
|
||||
</customwidgets>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
def test_app_has_widget_commands(runner: CliRunner):
|
||||
result = runner.invoke(main._app, ["create", "--help"])
|
||||
assert "widget" in result.output
|
||||
|
||||
|
||||
def test_create_widget_takes_name(runner: CliRunner):
|
||||
result = runner.invoke(main._app, ["create", "widget"])
|
||||
assert "Missing argument 'NAME'." in result.output
|
||||
|
||||
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.make_commit")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.git_stage_files")
|
||||
def test_make_commit(stage: MagicMock, commit: MagicMock, logger: MagicMock):
|
||||
repo = Path("test_path")
|
||||
_commit_added_widget(repo, "test")
|
||||
assert stage.call_count == 2
|
||||
stage.assert_has_calls(
|
||||
[
|
||||
call(repo, [".copier-answers.yml"]),
|
||||
call(repo / repo.name / "bec_widgets" / "widgets" / "test", []),
|
||||
]
|
||||
)
|
||||
commit.assert_called_with(repo, "plugin-manager added new widget: test")
|
||||
logger.info.assert_called_with("Committing new widget test")
|
||||
|
||||
|
||||
def test_widget_exists_function():
|
||||
assert not _widget_exists([], "test_widget")
|
||||
assert _widget_exists([{"name": "test_widget", "use_ui": True}], "test_widget")
|
||||
|
||||
|
||||
def test_editor_cb(runner):
|
||||
result = runner.invoke(main._app, ["create", "widget", "test", "--no-use-ui", "--open-editor"])
|
||||
assert result.exit_code == 2
|
||||
assert "Invalid value" in result.output
|
||||
assert "Can only open" in result.output
|
||||
|
||||
|
||||
class TestAddWidgetVariants:
|
||||
@pytest.fixture(scope="class", autouse=True)
|
||||
def setup_env(self, tmp_path_factory: pytest.TempPathFactory):
|
||||
TestAddWidgetVariants._tmp_plugin_dir = tmp_path_factory.mktemp("test_plugin")
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
def cleanup_repo(self):
|
||||
yield
|
||||
subprocess.run(["git", "reset", "--hard"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def git_repo(self):
|
||||
project = TestAddWidgetVariants._tmp_plugin_dir / "test_plugin"
|
||||
with _goto_dir(TestAddWidgetVariants._tmp_plugin_dir):
|
||||
subprocess.run(["git", "clone", PLUGIN_REPO])
|
||||
os.makedirs(project)
|
||||
with _goto_dir(project):
|
||||
subprocess.run(["git", "init", "-b", "main"])
|
||||
subprocess.run(["git", "config", "user.email", "test"])
|
||||
subprocess.run(["git", "config", "user.name", "test"])
|
||||
copier.run_copy(
|
||||
str(TestAddWidgetVariants._tmp_plugin_dir / "plugin_copier_template"),
|
||||
str(project),
|
||||
defaults=True,
|
||||
data={
|
||||
"project_name": "test_plugin",
|
||||
"widget_plugins_input": [{"name": "test_widget", "use_ui": True}],
|
||||
},
|
||||
unsafe=True,
|
||||
)
|
||||
yield project
|
||||
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
|
||||
def test_add_widget_with_ui(self, plugin_repo_path, runner: CliRunner, git_repo: Path):
|
||||
plugin_repo_path.return_value = str(git_repo)
|
||||
result = runner.invoke(
|
||||
main._app, ["create", "widget", "test_widget_2", "--use-ui", "--no-open-editor"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
widget_dir = git_repo / "test_plugin" / "bec_widgets" / "widgets" / "test_widget_2"
|
||||
assert os.path.isdir(widget_dir)
|
||||
assert os.path.isfile(widget_dir / "test_widget_2.py")
|
||||
assert os.path.isfile(widget_dir / "test_widget_2.ui")
|
||||
assert os.path.isfile(widget_dir / "test_widget_2_ui.py")
|
||||
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
|
||||
def test_add_widget_without_ui(self, plugin_repo_path, runner: CliRunner, git_repo: Path):
|
||||
plugin_repo_path.return_value = str(git_repo)
|
||||
result = runner.invoke(
|
||||
main._app, ["create", "widget", "test_widget_3", "--no-use-ui", "--no-open-editor"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
widget_dir = git_repo / "test_plugin" / "bec_widgets" / "widgets" / "test_widget_3"
|
||||
assert os.path.isdir(widget_dir)
|
||||
assert os.path.isfile(widget_dir / "test_widget_3.py")
|
||||
assert not os.path.isfile(widget_dir / "test_widget_3.ui")
|
||||
assert not os.path.isfile(widget_dir / "test_widget_3_ui.py")
|
||||
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
|
||||
def test_no_add_widget_dupe_name(
|
||||
self, plugin_repo_path, logger, runner: CliRunner, git_repo: Path
|
||||
):
|
||||
plugin_repo_path.return_value = str(git_repo)
|
||||
result = runner.invoke(
|
||||
main._app, ["create", "widget", "test_widget", "--no-use-ui", "--no-open-editor"]
|
||||
)
|
||||
assert result.exit_code == -1, result.output
|
||||
assert "already exists!" in logger.error.mock_calls[0].args[0]
|
||||
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
|
||||
def test_no_add_widget_bad_name(
|
||||
self, plugin_repo_path, logger, runner: CliRunner, git_repo: Path
|
||||
):
|
||||
plugin_repo_path.return_value = str(git_repo)
|
||||
result = runner.invoke(
|
||||
main._app, ["create", "widget", "12345", "--no-use-ui", "--no-open-editor"]
|
||||
)
|
||||
assert result.exit_code == -1, result.output
|
||||
assert "not a valid name for a widget" in logger.error.mock_calls[0].args[0]
|
||||
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.copier")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.logger")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
|
||||
def test_copier_error_logged(
|
||||
self, plugin_repo_path, logger, copier, runner: CliRunner, git_repo: Path
|
||||
):
|
||||
class CopierFailure(Exception): ...
|
||||
|
||||
copier.run_update.side_effect = CopierFailure
|
||||
plugin_repo_path.return_value = str(git_repo)
|
||||
result = runner.invoke(
|
||||
main._app, ["create", "widget", "test_widget_4", "--no-use-ui", "--no-open-editor"]
|
||||
)
|
||||
assert result.exit_code == -1, result.output
|
||||
assert "CopierFailure" in logger.error.mock_calls[0].args[0]
|
||||
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.open_and_watch_ui_editor")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
|
||||
def test_editor_opened_on_success(
|
||||
self, plugin_repo_path, open_editor, runner: CliRunner, git_repo: Path
|
||||
):
|
||||
plugin_repo_path.return_value = str(git_repo)
|
||||
runner.invoke(main._app, ["create", "widget", "TeSt_wiDgeT_5", "--use-ui", "--open-editor"])
|
||||
open_editor.assert_called_with("test_widget_5")
|
||||
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.edit_ui.logger")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.edit_ui.open_designer")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.edit_ui.plugin_repo_path")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.create.widget.plugin_repo_path")
|
||||
@patch("bec_widgets.utils.bec_plugin_manager.edit_ui.plugin_package_name")
|
||||
def test_widget_editor_watcher(
|
||||
self,
|
||||
plugin_package_name,
|
||||
plugin_repo_path,
|
||||
plugin_repo_path_2,
|
||||
open_designer,
|
||||
logger: MagicMock,
|
||||
runner: CliRunner,
|
||||
git_repo: Path,
|
||||
):
|
||||
plugin_repo_path.return_value = str(git_repo)
|
||||
plugin_repo_path_2.return_value = str(git_repo)
|
||||
plugin_package_name.return_value = git_repo.name
|
||||
|
||||
widget_dir = git_repo / "test_plugin" / "bec_widgets" / "widgets" / "test_widget_6"
|
||||
widget_ui_file = widget_dir / "test_widget_6.ui"
|
||||
compiled_widget_ui_file = widget_dir / "test_widget_6_ui.py"
|
||||
|
||||
test_collector = SimpleNamespace()
|
||||
|
||||
def test_function(args: list[str]):
|
||||
test_collector.ui_file = args[0]
|
||||
with open(compiled_widget_ui_file) as f:
|
||||
test_collector.initial_compiled_ui_contents = f.read()
|
||||
with open(args[0], "w") as f:
|
||||
f.write(REPLACEMENT_UI_CONTENTS)
|
||||
start = time.monotonic()
|
||||
while call("done!") not in logger.success.call_args_list:
|
||||
time.sleep(0.05)
|
||||
if time.monotonic() - start > 5:
|
||||
raise TimeoutError("Waiting for recompilation timed out.")
|
||||
with open(compiled_widget_ui_file) as f:
|
||||
test_collector.final_compiled_ui_contents = f.read()
|
||||
|
||||
open_designer.side_effect = test_function
|
||||
result = runner.invoke(
|
||||
main._app, ["create", "widget", "test_widget_6", "--use-ui", "--open-editor"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert test_collector.ui_file == str(widget_ui_file)
|
||||
assert (
|
||||
test_collector.initial_compiled_ui_contents != test_collector.final_compiled_ui_contents
|
||||
)
|
||||
assert "" in test_collector.final_compiled_ui_contents
|
||||
586
tests/unit_tests/test_property_editor.py
Normal file
586
tests/unit_tests/test_property_editor.py
Normal file
@@ -0,0 +1,586 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from qtpy import QtWidgets
|
||||
from qtpy.QtCore import QLocale, QPoint, QPointF, QRect, QRectF, QSize, QSizeF, Qt
|
||||
from qtpy.QtGui import QColor, QCursor, QFont, QIcon, QPalette
|
||||
from qtpy.QtWidgets import QLabel, QPushButton, QSizePolicy, QWidget
|
||||
|
||||
from bec_widgets.utils.property_editor import PropertyEditor
|
||||
|
||||
|
||||
class TestWidget(QWidget):
|
||||
"""Test widget with various property types for testing the property editor."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("TestWidget")
|
||||
# Set up various properties that will appear in the property editor
|
||||
self.setMinimumSize(100, 50)
|
||||
self.setMaximumSize(500, 300)
|
||||
self.setStyleSheet("background-color: red;")
|
||||
self.setToolTip("Test tooltip")
|
||||
self.setEnabled(True)
|
||||
self.setVisible(True)
|
||||
|
||||
|
||||
class BECTestWidget(QWidget):
|
||||
"""Test widget that simulates a BEC widget."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("BECTestWidget")
|
||||
# This widget's module will be set to simulate a bec_widgets module
|
||||
self.__module__ = "bec_widgets.test.widget"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_widget(qtbot):
|
||||
"""Fixture providing a test widget with various properties."""
|
||||
widget = TestWidget()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bec_test_widget(qtbot):
|
||||
"""Fixture providing a BEC test widget."""
|
||||
widget = BECTestWidget()
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
return widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def property_editor(qtbot, test_widget):
|
||||
"""Fixture providing a property editor with a test widget."""
|
||||
editor = PropertyEditor(test_widget, show_only_bec=False)
|
||||
qtbot.addWidget(editor)
|
||||
qtbot.waitExposed(editor)
|
||||
return editor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bec_property_editor(qtbot, bec_test_widget):
|
||||
"""Fixture providing a property editor with BEC-only mode."""
|
||||
editor = PropertyEditor(bec_test_widget, show_only_bec=True)
|
||||
qtbot.addWidget(editor)
|
||||
qtbot.waitExposed(editor)
|
||||
return editor
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Basic functionality tests
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_initialization(property_editor, test_widget):
|
||||
"""Test that the property editor initializes correctly."""
|
||||
assert property_editor._target == test_widget
|
||||
assert property_editor._bec_only is False
|
||||
assert property_editor.tree.columnCount() == 2
|
||||
assert property_editor.tree.headerItem().text(0) == "Property"
|
||||
assert property_editor.tree.headerItem().text(1) == "Value"
|
||||
|
||||
|
||||
def test_bec_only_mode(bec_property_editor):
|
||||
"""Test BEC-only mode filtering."""
|
||||
assert bec_property_editor._bec_only is True
|
||||
# Should have items since bec_test_widget simulates a BEC widget
|
||||
assert bec_property_editor.tree.topLevelItemCount() >= 0
|
||||
|
||||
|
||||
def test_class_chain(property_editor, test_widget):
|
||||
"""Test that _class_chain returns correct metaobject hierarchy."""
|
||||
chain = property_editor._class_chain()
|
||||
assert len(chain) > 0
|
||||
# First item should be the most derived class
|
||||
assert chain[0].className() in ["TestWidget", "QWidget"]
|
||||
|
||||
|
||||
def test_set_show_only_bec_toggle(property_editor):
|
||||
"""Test toggling BEC-only mode rebuilds the tree."""
|
||||
initial_count = property_editor.tree.topLevelItemCount()
|
||||
|
||||
# Toggle to BEC-only mode
|
||||
property_editor.set_show_only_bec(True)
|
||||
assert property_editor._bec_only is True
|
||||
|
||||
# Toggle back
|
||||
property_editor.set_show_only_bec(False)
|
||||
assert property_editor._bec_only is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Editor creation tests
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_make_sizepolicy_editor(property_editor):
|
||||
"""Test size policy editor creation and functionality."""
|
||||
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
size_policy.setHorizontalStretch(1)
|
||||
size_policy.setVerticalStretch(2)
|
||||
|
||||
editor = property_editor._make_sizepolicy_editor("sizePolicy", size_policy)
|
||||
assert editor is not None
|
||||
|
||||
# Should return None for non-QSizePolicy input
|
||||
editor_none = property_editor._make_sizepolicy_editor("test", "not_a_sizepolicy")
|
||||
assert editor_none is None
|
||||
|
||||
|
||||
def test_make_locale_editor(property_editor):
|
||||
"""Test locale editor creation."""
|
||||
locale = QLocale(QLocale.English, QLocale.UnitedStates)
|
||||
editor = property_editor._make_locale_editor("locale", locale)
|
||||
assert editor is not None
|
||||
|
||||
# Should return None for non-QLocale input
|
||||
editor_none = property_editor._make_locale_editor("test", "not_a_locale")
|
||||
assert editor_none is None
|
||||
|
||||
|
||||
def test_make_icon_editor(property_editor):
|
||||
"""Test icon editor creation."""
|
||||
icon = QIcon()
|
||||
editor = property_editor._make_icon_editor("icon", icon)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QPushButton)
|
||||
assert "Choose" in editor.text()
|
||||
|
||||
|
||||
def test_make_font_editor(property_editor):
|
||||
"""Test font editor creation."""
|
||||
font = QFont("Arial", 12)
|
||||
editor = property_editor._make_font_editor("font", font)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QPushButton)
|
||||
assert "Arial" in editor.text()
|
||||
assert "12" in editor.text()
|
||||
|
||||
# Test with non-font value
|
||||
editor_no_font = property_editor._make_font_editor("font", None)
|
||||
assert "Select font" in editor_no_font.text()
|
||||
|
||||
|
||||
def test_make_color_editor(property_editor):
|
||||
"""Test color editor creation."""
|
||||
color = QColor(255, 0, 0) # Red color
|
||||
apply_called = []
|
||||
|
||||
def apply_callback(col):
|
||||
apply_called.append(col)
|
||||
|
||||
editor = property_editor._make_color_editor(color, apply_callback)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QPushButton)
|
||||
assert color.name() in editor.text()
|
||||
|
||||
|
||||
def test_make_cursor_editor(property_editor):
|
||||
"""Test cursor editor creation."""
|
||||
cursor = QCursor(Qt.CrossCursor)
|
||||
editor = property_editor._make_cursor_editor("cursor", cursor)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QtWidgets.QComboBox)
|
||||
|
||||
|
||||
def test_spin_pair_int(property_editor):
|
||||
"""Test _spin_pair with integer spinboxes."""
|
||||
wrap, box1, box2 = property_editor._spin_pair(ints=True)
|
||||
assert wrap is not None
|
||||
assert isinstance(box1, QtWidgets.QSpinBox)
|
||||
assert isinstance(box2, QtWidgets.QSpinBox)
|
||||
assert box1.minimum() == -10_000_000
|
||||
assert box1.maximum() == 10_000_000
|
||||
|
||||
|
||||
def test_spin_pair_float(property_editor):
|
||||
"""Test _spin_pair with double spinboxes."""
|
||||
wrap, box1, box2 = property_editor._spin_pair(ints=False)
|
||||
assert wrap is not None
|
||||
assert isinstance(box1, QtWidgets.QDoubleSpinBox)
|
||||
assert isinstance(box2, QtWidgets.QDoubleSpinBox)
|
||||
assert box1.decimals() == 6
|
||||
|
||||
|
||||
def test_spin_quad_int(property_editor):
|
||||
"""Test _spin_quad with integer spinboxes."""
|
||||
wrap, boxes = property_editor._spin_quad(ints=True)
|
||||
assert wrap is not None
|
||||
assert len(boxes) == 4
|
||||
assert all(isinstance(box, QtWidgets.QSpinBox) for box in boxes)
|
||||
|
||||
|
||||
def test_spin_quad_float(property_editor):
|
||||
"""Test _spin_quad with double spinboxes."""
|
||||
wrap, boxes = property_editor._spin_quad(ints=False)
|
||||
assert wrap is not None
|
||||
assert len(boxes) == 4
|
||||
assert all(isinstance(box, QtWidgets.QDoubleSpinBox) for box in boxes)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Property type editor tests
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_make_editor_qsize(property_editor):
|
||||
"""Test editor creation for QSize properties."""
|
||||
size = QSize(100, 200)
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("size", size, mock_prop)
|
||||
assert editor is not None
|
||||
|
||||
|
||||
def test_make_editor_qsizef(property_editor):
|
||||
"""Test editor creation for QSizeF properties."""
|
||||
sizef = QSizeF(100.5, 200.7)
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("sizef", sizef, mock_prop)
|
||||
assert editor is not None
|
||||
|
||||
|
||||
def test_make_editor_qpoint(property_editor):
|
||||
"""Test editor creation for QPoint properties."""
|
||||
point = QPoint(10, 20)
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("point", point, mock_prop)
|
||||
assert editor is not None
|
||||
|
||||
|
||||
def test_make_editor_qpointf(property_editor):
|
||||
"""Test editor creation for QPointF properties."""
|
||||
pointf = QPointF(10.5, 20.7)
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("pointf", pointf, mock_prop)
|
||||
assert editor is not None
|
||||
|
||||
|
||||
def test_make_editor_qrect(property_editor):
|
||||
"""Test editor creation for QRect properties."""
|
||||
rect = QRect(10, 20, 100, 200)
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("rect", rect, mock_prop)
|
||||
assert editor is not None
|
||||
|
||||
|
||||
def test_make_editor_qrectf(property_editor):
|
||||
"""Test editor creation for QRectF properties."""
|
||||
rectf = QRectF(10.5, 20.7, 100.5, 200.7)
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("rectf", rectf, mock_prop)
|
||||
assert editor is not None
|
||||
|
||||
|
||||
def test_make_editor_bool(property_editor):
|
||||
"""Test editor creation for boolean properties."""
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("enabled", True, mock_prop)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QtWidgets.QCheckBox)
|
||||
assert editor.isChecked() is True
|
||||
|
||||
|
||||
def test_make_editor_int(property_editor):
|
||||
"""Test editor creation for integer properties."""
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("value", 42, mock_prop)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QtWidgets.QSpinBox)
|
||||
assert editor.value() == 42
|
||||
|
||||
|
||||
def test_make_editor_float(property_editor):
|
||||
"""Test editor creation for float properties."""
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("value", 3.14, mock_prop)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QtWidgets.QDoubleSpinBox)
|
||||
assert editor.value() == 3.14
|
||||
|
||||
|
||||
def test_make_editor_string(property_editor):
|
||||
"""Test editor creation for string properties."""
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("text", "Hello World", mock_prop)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QtWidgets.QLineEdit)
|
||||
assert editor.text() == "Hello World"
|
||||
|
||||
|
||||
def test_make_editor_qcolor(property_editor):
|
||||
"""Test editor creation for QColor properties."""
|
||||
color = QColor(255, 0, 0)
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("color", color, mock_prop)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QPushButton)
|
||||
|
||||
|
||||
def test_make_editor_qfont(property_editor):
|
||||
"""Test editor creation for QFont properties."""
|
||||
font = QFont("Arial", 12)
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
editor = property_editor._make_editor("font", font, mock_prop)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QPushButton)
|
||||
|
||||
|
||||
def test_make_editor_unsupported_type(property_editor):
|
||||
"""Test editor creation for unsupported property types."""
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
# Should return None for unsupported types
|
||||
editor = property_editor._make_editor("unsupported", object(), mock_prop)
|
||||
assert editor is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Enum editor tests
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_make_enum_editor_non_flag(property_editor):
|
||||
"""Test enum editor creation for non-flag enums."""
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = True
|
||||
|
||||
mock_enum = Mock()
|
||||
mock_enum.isFlag.return_value = False
|
||||
mock_enum.keyCount.return_value = 3
|
||||
mock_enum.key.side_effect = [b"Value1", b"Value2", b"Value3"]
|
||||
mock_enum.value.side_effect = [0, 1, 2]
|
||||
mock_prop.enumerator.return_value = mock_enum
|
||||
|
||||
editor = property_editor._make_enum_editor("enum_prop", 1, mock_prop)
|
||||
assert editor is not None
|
||||
assert isinstance(editor, QtWidgets.QComboBox)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Palette editor tests
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_make_palette_editor(property_editor):
|
||||
"""Test palette editor creation."""
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.Window, QColor(255, 255, 255))
|
||||
|
||||
editor = property_editor._make_palette_editor("palette", palette)
|
||||
assert editor is not None
|
||||
|
||||
# Should return None for non-QPalette input
|
||||
editor_none = property_editor._make_palette_editor("test", "not_a_palette")
|
||||
assert editor_none is None
|
||||
|
||||
|
||||
def test_apply_palette_color(property_editor, test_widget):
|
||||
"""Test _apply_palette_color method."""
|
||||
palette = test_widget.palette()
|
||||
original_color = palette.color(QPalette.Active, QPalette.Window)
|
||||
new_color = QColor(255, 0, 0)
|
||||
|
||||
property_editor._apply_palette_color(
|
||||
"palette", palette, QPalette.Active, QPalette.Window, new_color
|
||||
)
|
||||
|
||||
# Verify the property was set (this would normally update the widget)
|
||||
assert palette.color(QPalette.Active, QPalette.Window) == new_color
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Enum text processing tests
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_enum_text_non_flag(property_editor):
|
||||
"""Test _enum_text for non-flag enums."""
|
||||
mock_enum = Mock()
|
||||
mock_enum.isFlag.return_value = False
|
||||
mock_enum.valueToKey.return_value = b"TestValue"
|
||||
|
||||
result = property_editor._enum_text(mock_enum, 1)
|
||||
assert result == "TestValue"
|
||||
|
||||
|
||||
def test_enum_text_flag(property_editor):
|
||||
"""Test _enum_text for flag enums."""
|
||||
mock_enum = Mock()
|
||||
mock_enum.isFlag.return_value = True
|
||||
mock_enum.keyCount.return_value = 2
|
||||
mock_enum.key.side_effect = [b"Flag1", b"Flag2"]
|
||||
mock_enum.value.side_effect = [1, 2]
|
||||
|
||||
result = property_editor._enum_text(mock_enum, 3) # 1 | 2 = 3
|
||||
assert "Flag1" in result and "Flag2" in result
|
||||
|
||||
|
||||
def test_enum_value_to_int(property_editor):
|
||||
"""Test _enum_value_to_int conversion."""
|
||||
# Test with integer
|
||||
assert property_editor._enum_value_to_int(Mock(), 42) == 42
|
||||
|
||||
# Test with object having value attribute
|
||||
mock_obj = Mock()
|
||||
mock_obj.value = 24
|
||||
assert property_editor._enum_value_to_int(Mock(), mock_obj) == 24
|
||||
|
||||
# Test with mock enum for key lookup
|
||||
mock_enum = Mock()
|
||||
mock_enum.keyToValue.return_value = 10
|
||||
mock_obj_with_name = Mock()
|
||||
mock_obj_with_name.name = "TestKey"
|
||||
assert property_editor._enum_value_to_int(mock_enum, mock_obj_with_name) == 10
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Tree building and interaction tests
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_property_row(property_editor):
|
||||
"""Test _add_property_row method."""
|
||||
parent_item = QtWidgets.QTreeWidgetItem(["TestGroup"])
|
||||
mock_prop = Mock()
|
||||
mock_prop.isEnumType.return_value = False
|
||||
|
||||
property_editor._add_property_row(parent_item, "testProp", "testValue", mock_prop)
|
||||
assert parent_item.childCount() == 1
|
||||
|
||||
child = parent_item.child(0)
|
||||
assert child.text(0) == "testProp"
|
||||
|
||||
|
||||
def test_set_equal_columns(property_editor):
|
||||
"""Test _set_equal_columns method."""
|
||||
# Set a specific width to test column sizing
|
||||
property_editor.resize(400, 300)
|
||||
property_editor._set_equal_columns()
|
||||
|
||||
# Verify columns are set up correctly
|
||||
header = property_editor.tree.header()
|
||||
assert header.sectionResizeMode(0) == QtWidgets.QHeaderView.Interactive
|
||||
assert header.sectionResizeMode(1) == QtWidgets.QHeaderView.Interactive
|
||||
|
||||
|
||||
def test_build_rebuilds_tree(property_editor):
|
||||
"""Test that _build method clears and rebuilds the tree."""
|
||||
initial_count = property_editor.tree.topLevelItemCount()
|
||||
|
||||
# Add a dummy item to ensure clearing works
|
||||
dummy_item = QtWidgets.QTreeWidgetItem(["Dummy"])
|
||||
property_editor.tree.addTopLevelItem(dummy_item)
|
||||
|
||||
# Rebuild
|
||||
property_editor._build()
|
||||
|
||||
# The dummy item should be gone, tree should be rebuilt
|
||||
assert property_editor.tree.topLevelItemCount() >= 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Integration tests with Qt objects
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_property_change_integration(qtbot, property_editor, test_widget):
|
||||
"""Test that property changes through editors update the target widget."""
|
||||
# This test would require more complex setup to actually trigger editor changes
|
||||
# For now, just verify the basic structure is there
|
||||
assert property_editor._target == test_widget
|
||||
|
||||
# Verify that the tree has been populated with some properties
|
||||
assert property_editor.tree.topLevelItemCount() >= 0
|
||||
|
||||
|
||||
def test_widget_with_custom_properties(qtbot):
|
||||
"""Test property editor with a widget that has custom properties."""
|
||||
widget = QLabel("Test Label")
|
||||
widget.setAlignment(Qt.AlignCenter)
|
||||
widget.setWordWrap(True)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
editor = PropertyEditor(widget, show_only_bec=False)
|
||||
qtbot.addWidget(editor)
|
||||
qtbot.waitExposed(editor)
|
||||
|
||||
# Should have populated the tree with QLabel properties
|
||||
assert editor.tree.topLevelItemCount() > 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Error handling tests
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_robust_enum_handling(property_editor):
|
||||
"""Test that enum handling is robust against various edge cases."""
|
||||
# Test with invalid enum values
|
||||
mock_enum = Mock()
|
||||
mock_enum.isFlag.return_value = False
|
||||
mock_enum.valueToKey.return_value = None
|
||||
|
||||
result = property_editor._enum_text(mock_enum, 999)
|
||||
assert result == "999" # Should fall back to string representation
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Performance and memory tests
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_large_property_tree_performance(qtbot):
|
||||
"""Test that the property editor handles widgets with many properties reasonably."""
|
||||
# Create a widget with a deep inheritance hierarchy
|
||||
widget = QtWidgets.QTextEdit()
|
||||
widget.setPlainText("Test text with many properties")
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
editor = PropertyEditor(widget, show_only_bec=False)
|
||||
qtbot.addWidget(editor)
|
||||
|
||||
# Should complete without hanging
|
||||
qtbot.waitExposed(editor)
|
||||
assert editor.tree.topLevelItemCount() > 0
|
||||
|
||||
|
||||
def test_memory_cleanup_on_rebuild(property_editor):
|
||||
"""Test that rebuilding the tree properly cleans up widgets."""
|
||||
initial_count = property_editor.tree.topLevelItemCount()
|
||||
|
||||
# Trigger multiple rebuilds
|
||||
for _ in range(3):
|
||||
property_editor._build()
|
||||
|
||||
# Should not accumulate items
|
||||
final_count = property_editor.tree.topLevelItemCount()
|
||||
assert final_count >= 0 # Basic sanity check
|
||||
@@ -210,6 +210,15 @@ available_scans_message = AvailableResourceMessage(
|
||||
"default": False,
|
||||
"expert": False,
|
||||
},
|
||||
{
|
||||
"arg": False,
|
||||
"name": "optim_trajectory",
|
||||
"type": {"Literal": ("option1", "option2", "option3", None)},
|
||||
"display_name": "Optim Trajectory",
|
||||
"tooltip": None,
|
||||
"default": None,
|
||||
"expert": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -304,7 +313,10 @@ def test_on_scan_selected(scan_control, scan_name):
|
||||
label = kwarg_box.layout.itemAtPosition(0, index).widget()
|
||||
assert label.text() == kwarg_info["display_name"]
|
||||
widget = kwarg_box.layout.itemAtPosition(1, index).widget()
|
||||
expected_widget_type = kwarg_box.WIDGET_HANDLER.get(kwarg_info["type"], None)
|
||||
if isinstance(kwarg_info["type"], dict) and "Literal" in kwarg_info["type"]:
|
||||
expected_widget_type = kwarg_box.WIDGET_HANDLER.get("dict", None)
|
||||
else:
|
||||
expected_widget_type = kwarg_box.WIDGET_HANDLER.get(kwarg_info["type"], None)
|
||||
assert isinstance(widget, expected_widget_type)
|
||||
|
||||
|
||||
@@ -441,7 +453,7 @@ def test_run_grid_scan_with_parameters(scan_control, mocked_client):
|
||||
args_row2["steps"],
|
||||
]
|
||||
assert called_args == tuple(expected_args_list)
|
||||
assert called_kwargs == kwargs | {"metadata": {"sample_name": ""}}
|
||||
assert called_kwargs == kwargs | {"metadata": {"sample_name": ""}, "optim_trajectory": None}
|
||||
|
||||
# Check the emitted signal
|
||||
mock_slot.assert_called_once()
|
||||
|
||||
@@ -287,6 +287,23 @@ def test_scan_history_view_refresh(qtbot, scan_history_view, scan_history_msg, s
|
||||
assert scan_history_view.topLevelItemCount() == 0
|
||||
|
||||
|
||||
def test_scan_history_update_full_history(
|
||||
qtbot, scan_history_view, scan_history_msg, scan_history_msg_2
|
||||
):
|
||||
"""Test the update_full_history method of ScanHistoryView."""
|
||||
# Wait spinner should be visible
|
||||
scan_history_view.update_full_history(
|
||||
[scan_history_msg.model_dump(), scan_history_msg_2.model_dump()]
|
||||
)
|
||||
assert len(scan_history_view.scan_history) == 2
|
||||
assert scan_history_view.topLevelItemCount() == 2
|
||||
assert scan_history_view.scan_history[0] == scan_history_msg_2 # new first item
|
||||
assert scan_history_view.scan_history[1] == scan_history_msg # old second item
|
||||
# Wait spinner should be hidden
|
||||
assert scan_history_view._overlay_widget.isVisible() is False
|
||||
assert scan_history_view._spinner.isVisible() is False
|
||||
|
||||
|
||||
def test_scan_history_browser(qtbot, scan_history_browser, scan_history_msg, scan_history_msg_2):
|
||||
"""Test the initialization of ScanHistoryBrowser."""
|
||||
assert isinstance(scan_history_browser.scan_history_view, ScanHistoryView)
|
||||
@@ -298,14 +315,14 @@ def test_scan_history_browser(qtbot, scan_history_browser, scan_history_msg, sca
|
||||
scan_history_browser.scan_history_view.update_history(scan_history_msg_2.model_dump())
|
||||
|
||||
assert len(scan_history_browser.scan_history_view.scan_history) == 2
|
||||
assert scan_history_browser.scan_history_view.topLevelItemCount() == 2
|
||||
# Click on first scan item history to select it
|
||||
qtbot.mouseClick(
|
||||
scan_history_browser.scan_history_view.viewport(),
|
||||
QtCore.Qt.LeftButton,
|
||||
pos=scan_history_browser.scan_history_view.visualItemRect(
|
||||
scan_history_browser.scan_history_view.topLevelItem(0)
|
||||
).center(),
|
||||
)
|
||||
# TODO #771 ; Multiple clicks to the QTreeView item fail, but only in the CI, not locally.
|
||||
# Simulate a mouse click without qtbot.mouseClick as this is unstable and currently fails in CI
|
||||
item = scan_history_browser.scan_history_view.topLevelItem(0)
|
||||
scan_history_browser.scan_history_view.setCurrentItem(item)
|
||||
scan_history_browser.scan_history_view.itemClicked.emit(item, 0)
|
||||
|
||||
assert scan_history_browser.scan_history_view.currentIndex().row() == 0
|
||||
|
||||
# Both metadata and device viewers should be updated with the first scan
|
||||
@@ -320,29 +337,6 @@ def test_scan_history_browser(qtbot, scan_history_browser, scan_history_msg, sca
|
||||
timeout=2000,
|
||||
)
|
||||
|
||||
# TODO #771 ; Multiple clicks to the QTreeView item fail, but only in the CI, not locally.
|
||||
# Click on second scan item history to select it
|
||||
# qtbot.mouseClick(
|
||||
# scan_history_browser.scan_history_view.viewport(),
|
||||
# QtCore.Qt.LeftButton,
|
||||
# pos=scan_history_browser.scan_history_view.visualItemRect(
|
||||
# scan_history_browser.scan_history_view.topLevelItem(1)
|
||||
# ).center(),
|
||||
# )
|
||||
# assert scan_history_browser.scan_history_view.currentIndex().row() == 1
|
||||
|
||||
# # Both metadata and device viewers should be updated with the first scan
|
||||
# qtbot.waitUntil(
|
||||
# lambda: scan_history_browser.scan_history_metadata_viewer.scan_history_msg
|
||||
# == scan_history_msg,
|
||||
# timeout=2000,
|
||||
# )
|
||||
# qtbot.waitUntil(
|
||||
# lambda: scan_history_browser.scan_history_device_viewer.scan_history_msg
|
||||
# == scan_history_msg,
|
||||
# timeout=2000,
|
||||
# )
|
||||
|
||||
callback_args = []
|
||||
|
||||
def plotting_callback(device_name, signal_name, msg):
|
||||
|
||||
118
tests/unit_tests/test_script_tree_widget.py
Normal file
118
tests/unit_tests/test_script_tree_widget.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QEvent, Qt
|
||||
from qtpy.QtGui import QMouseEvent
|
||||
|
||||
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def script_tree(qtbot, tmpdir):
|
||||
"""Create a ScriptTreeWidget with the tmpdir directory"""
|
||||
# Create test files and directories
|
||||
(Path(tmpdir) / "test_file.py").touch()
|
||||
(Path(tmpdir) / "test_dir").mkdir()
|
||||
(Path(tmpdir) / "test_dir" / "nested_file.py").touch()
|
||||
|
||||
widget = ScriptTreeWidget()
|
||||
widget.set_directory(str(tmpdir))
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_script_tree_set_directory(script_tree, tmpdir):
|
||||
"""Test setting the directory"""
|
||||
assert script_tree.directory == str(tmpdir)
|
||||
|
||||
|
||||
def test_script_tree_hover_events(script_tree, qtbot):
|
||||
"""Test mouse hover events and actions button visibility"""
|
||||
|
||||
# Get the tree view and its viewport
|
||||
tree_view = script_tree.tree
|
||||
viewport = tree_view.viewport()
|
||||
|
||||
# Find the position of the first item (test_file.py)
|
||||
index = script_tree.proxy_model.index(0, 0) # first item
|
||||
rect = tree_view.visualRect(index)
|
||||
pos = rect.center()
|
||||
|
||||
# Initially, no item should be hovered
|
||||
assert script_tree.delegate.hovered_index.isValid() == False
|
||||
|
||||
# Simulate a mouse move event over the item
|
||||
mouse_event = QMouseEvent(
|
||||
QEvent.Type.MouseMove,
|
||||
pos,
|
||||
tree_view.mapToGlobal(pos),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier,
|
||||
)
|
||||
|
||||
# Send the event to the viewport (the event filter is installed on the viewport)
|
||||
script_tree.eventFilter(viewport, mouse_event)
|
||||
|
||||
qtbot.wait(100) # Allow time for the hover to be processed
|
||||
|
||||
# Now, the hover index should be set to the first item
|
||||
assert script_tree.delegate.hovered_index.isValid() == True
|
||||
assert script_tree.delegate.hovered_index.row() == index.row()
|
||||
|
||||
# Simulate mouse leaving the viewport
|
||||
leave_event = QEvent(QEvent.Type.Leave)
|
||||
script_tree.eventFilter(viewport, leave_event)
|
||||
|
||||
qtbot.wait(100) # Allow time for the leave event to be processed
|
||||
|
||||
# After leaving, no item should be hovered
|
||||
assert script_tree.delegate.hovered_index.isValid() == False
|
||||
|
||||
|
||||
@pytest.mark.timeout(10)
|
||||
def test_script_tree_on_item_clicked(script_tree, qtbot, tmpdir):
|
||||
"""Test that _on_item_clicked emits file_selected signal only for Python files"""
|
||||
|
||||
file_selected_signals = []
|
||||
file_open_requested_signals = []
|
||||
|
||||
def on_file_selected(file_path):
|
||||
file_selected_signals.append(file_path)
|
||||
|
||||
def on_file_open_requested(file_path):
|
||||
file_open_requested_signals.append(file_path)
|
||||
|
||||
# Connect to the signal
|
||||
script_tree.file_selected.connect(on_file_selected)
|
||||
script_tree.file_open_requested.connect(on_file_open_requested)
|
||||
|
||||
# Wait until the model sees test_file.py
|
||||
def has_py_file():
|
||||
nonlocal py_file_index
|
||||
root_index = script_tree.tree.rootIndex()
|
||||
for i in range(script_tree.proxy_model.rowCount(root_index)):
|
||||
index = script_tree.proxy_model.index(i, 0, root_index)
|
||||
source_index = script_tree.proxy_model.mapToSource(index)
|
||||
if script_tree.model.fileName(source_index) == "test_file.py":
|
||||
py_file_index = index
|
||||
return True
|
||||
return False
|
||||
|
||||
py_file_index = None
|
||||
qtbot.waitUntil(has_py_file)
|
||||
|
||||
# Simulate clicking on the center of the item
|
||||
script_tree._on_item_clicked(py_file_index)
|
||||
qtbot.wait(100) # Allow time for the click to be processed
|
||||
|
||||
py_file_index = None
|
||||
qtbot.waitUntil(has_py_file)
|
||||
|
||||
script_tree._on_item_double_clicked(py_file_index)
|
||||
qtbot.wait(100)
|
||||
|
||||
# Verify the signal was emitted with the correct path
|
||||
assert len(file_selected_signals) == 1
|
||||
assert Path(file_selected_signals[0]).name == "test_file.py"
|
||||
@@ -215,9 +215,7 @@ def test_set_existing_device_and_signal(signal_label: SignalLabel, qtbot):
|
||||
signal_label.device = "samx"
|
||||
signal_label.signal = "readback"
|
||||
assert signal_label._device == "samx"
|
||||
assert signal_label._config.device == "samx"
|
||||
assert signal_label._signal == "readback"
|
||||
assert signal_label._config.default == "readback"
|
||||
|
||||
|
||||
def test_set_nonexisting_device_and_signal(signal_label: SignalLabel, qtbot):
|
||||
@@ -225,12 +223,10 @@ def test_set_nonexisting_device_and_signal(signal_label: SignalLabel, qtbot):
|
||||
signal_label.device = "samq"
|
||||
signal_label.signal = "readfront"
|
||||
assert signal_label._device == "samq"
|
||||
assert signal_label._config.device == "samq"
|
||||
signal_label._manual_read()
|
||||
signal_label.set_display_value(signal_label._value)
|
||||
assert signal_label._display.text() == "__"
|
||||
assert signal_label._signal == "readfront"
|
||||
assert signal_label._config.default == "readfront"
|
||||
signal_label._manual_read()
|
||||
signal_label.set_display_value(signal_label._value)
|
||||
assert signal_label._display.text() == "__"
|
||||
@@ -256,3 +252,12 @@ def test_handle_readback(signal_label: SignalLabel, qtbot):
|
||||
)
|
||||
assert signal_label._display.text() == "0.993 μm"
|
||||
assert signal_label._display.toolTip() == ""
|
||||
|
||||
|
||||
def test_handle_lists(signal_label: SignalLabel, qtbot):
|
||||
signal_label.custom_units = ""
|
||||
signal_label.set_display_value([1, 2, 3, 4])
|
||||
assert signal_label._display.text() == "[1, 2, 3, 4]"
|
||||
signal_label.max_list_display_len = 2
|
||||
signal_label.set_display_value([1, 2, 3, 4])
|
||||
assert signal_label._display.text() == "ARRAY DATA"
|
||||
|
||||
Reference in New Issue
Block a user