1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-08 09:47:52 +02:00

Compare commits

...

86 Commits

Author SHA1 Message Date
74e045b631 refactor: refactor device_manager_view 2025-09-02 08:33:35 +02:00
adbf624780 feat: display warning for multiple files 2025-09-01 20:42:49 +02:00
a49d9bbbd1 wip: add slot to update state from table entries 2025-09-01 20:42:49 +02:00
0a767d268c wip: make buttons do stuff 2025-09-01 20:42:49 +02:00
2e0f952209 feat: available resources basic structure 2025-09-01 20:42:49 +02:00
d96df27b4d feat(dm-view): initial commit for config_view, ophyd_test and dm_widget 2025-09-01 20:42:49 +02:00
b5921adb36 test: remove outdated tests
Note: The stylesheet is now set by qthemes, not the widget itself. As a result, the widget-specific stylesheet remains empty.
2025-09-01 15:16:40 +02:00
7d19427d0a feat: add SafeConnect 2025-09-01 15:16:40 +02:00
18c4cc6a9e fix: process all deletion events before applying a new theme.
Note: this can be dropped once qthemes is updated.
2025-09-01 15:16:40 +02:00
2e185a41fe refactor: move to qthemes 1.1.2 2025-09-01 15:16:40 +02:00
36a3d56828 test: apply theme on qapp creation 2025-09-01 15:16:40 +02:00
bc99b4e9d3 refactor(spinner): improve enum access 2025-09-01 15:16:40 +02:00
a96349b89c fix(themes): move apply theme from BECWidget class to server init 2025-09-01 15:16:40 +02:00
2807b73f5b fix(BECWidget): ensure that theme changes are only triggered from alive Qt objects 2025-09-01 15:16:40 +02:00
b1cd556466 test: fix tests for qtheme v1 2025-09-01 15:16:40 +02:00
0be07ca77e fix(serializer): remove deprecated serializer 2025-09-01 15:16:40 +02:00
dc47742245 ci: add artifact upload 2025-09-01 15:16:40 +02:00
39176bfeff test: fixes after theme changes 2025-09-01 15:16:40 +02:00
52dbd1df9e build: add missing darkdetect dependency 2025-09-01 15:16:40 +02:00
fd1a722a4f fix(compact_popup): import from qtpy instead of pyside6 2025-09-01 15:16:40 +02:00
a2e0372cc8 chore: fix formatter 2025-09-01 15:16:40 +02:00
c10f2f19a6 fix: compact popup layout spacing 2025-09-01 15:16:40 +02:00
0bec727f09 fix: remove pyqtgraph styling logic 2025-09-01 15:16:40 +02:00
46fed70dd6 fix: tree items due to pushbutton margins 2025-09-01 15:16:40 +02:00
476773ee0c fix: device combobox change paint event to stylesheet change 2025-09-01 15:16:40 +02:00
fcee157358 fix(toolbar): toolbar menu button fixed 2025-09-01 15:16:40 +02:00
da925783a5 fix:queue abort button fixed 2025-09-01 15:16:40 +02:00
818e78104d fix(bec_widgets): adapt to bec_qthemes 1.0 2025-09-01 15:16:40 +02:00
bf0667aac7 build(bec_qthemes): version 1.0 dependency 2025-09-01 15:16:40 +02:00
a27f66bbef refactor(advanced_dock_area): profile tools moved to separate module 2025-08-21 15:38:25 +02:00
66fb0a8816 fix(advanced_dock_area): dock manager global flags initialised in BW init to prevent segfault 2025-08-21 15:38:25 +02:00
b626a4b4ed feat(advanced_dock_area): ads has default direction 2025-08-21 15:38:25 +02:00
af21720700 refactor(advanced_dock_area): ads changed to separate widget 2025-08-21 15:38:25 +02:00
0fff996aae fix(bec_widgets): by default the linux display manager is switched to xcb 2025-08-21 15:38:25 +02:00
52ef184df1 feat(advanced_dock_area): added ads based dock area with profiles 2025-08-21 15:38:25 +02:00
1ff943a2eb refactor(bec_main_window): main app theme renamed to View 2025-08-21 15:38:25 +02:00
b65a2f0d8c feat(bec_widget): attach/detach method for all widgets + client regenerated 2025-08-21 15:38:25 +02:00
963c8127cf fix(widget_state_manager): state manager can save to already existing settings
wip widget state manager saving loading file logic
2025-08-21 15:38:25 +02:00
43bec1b460 fix(widget_state_manager): state manager can save all properties recursively 2025-08-21 15:38:25 +02:00
01d9689772 refactor(widget_io): ancestor hierarchy methods consolidated 2025-08-21 15:38:25 +02:00
77aaff878b feat(widget_io): widget hierarchy find_ancestor added 2025-08-21 15:38:25 +02:00
2ef65b3610 feat(widget_io): widget hierarchy can grap all bec connectors from the widget recursively 2025-08-21 15:38:25 +02:00
5a364eed48 refactor(bec_connector): signals renamed 2025-08-21 15:38:25 +02:00
956f2999c2 fix(bec_connector): added name established signal for listeners 2025-08-21 15:38:25 +02:00
9c8c3e0cc3 fix(bec_connector): dedicated remove signal added for listeners 2025-08-21 15:38:25 +02:00
2aa32d150d build: PySide6-QtAds dependency added 2025-08-21 15:38:25 +02:00
semantic-release
ba047fd776 2.38.0
Automatically generated by python-semantic-release
2025-08-19 15:12:14 +00:00
6e05157abb feat(device_manager): DeviceManager view of config session 2025-08-19 17:11:24 +02:00
semantic-release
f4bc759e72 2.37.0
Automatically generated by python-semantic-release
2025-08-19 14:52:20 +00:00
1bec9bd9b2 feat: add explorer widget 2025-08-19 16:51:38 +02:00
semantic-release
8b013d5dce 2.36.0
Automatically generated by python-semantic-release
2025-08-18 10:45:14 +00:00
f2e5a85e61 feat(scan control): add support for literals 2025-08-18 12:44:29 +02:00
semantic-release
a2f8880459 2.35.0
Automatically generated by python-semantic-release
2025-08-14 07:16:53 +00:00
926d722955 feat(property_manager): property manager widget 2025-08-14 09:16:04 +02:00
44ba7201b4 build: PySide6 upgraded to 6.9.0 2025-08-12 19:56:29 +02:00
semantic-release
0717426db2 2.34.0
Automatically generated by python-semantic-release
2025-08-07 13:39:47 +00:00
f4af6ebc5f fix: use better source for plugin repo name 2025-08-07 15:39:07 +02:00
a923f12c97 feat: autoformat compiled file and add docs 2025-08-07 15:39:07 +02:00
a5a7607a83 tests: add tests for widget creator 2025-08-07 15:39:07 +02:00
9de548446b fix: plugin widget import machinery
- 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
2025-08-07 15:39:07 +02:00
49ac7decf7 feat(plugin manager): add cli commands 2025-08-07 15:39:07 +02:00
semantic-release
092bed38fa 2.33.3
Automatically generated by python-semantic-release
2025-07-31 11:10:38 +00:00
50c84a766a refactor(scan-history): add spinner for loading time of history 2025-07-31 13:09:47 +02:00
d22a3317ba refactor: use client callback for scan history reload 2025-07-31 13:09:47 +02:00
6df1d0c31f fix(scan-history-view): account for async loading of scan history 2025-07-31 13:09:47 +02:00
946752a4b0 refactor(scan-history): fix insert logic; cleanup 2025-07-31 13:09:47 +02:00
c1f62ad6cb refactor: make ids a set, cleanup 2025-07-31 13:09:47 +02:00
a5adf3a97d refactor: improve scan history performance on loading full scan lists 2025-07-31 13:09:47 +02:00
semantic-release
76e3e0b60f 2.33.2
Automatically generated by python-semantic-release
2025-07-31 07:27:50 +00:00
f18eeb9c5d fix: don't warn on empty DeviceEdit init 2025-07-31 09:26:59 +02:00
32ce8e2818 fix: remove config, directly set device+signal 2025-07-31 09:26:59 +02:00
23413cffab fix: delete choice dialog on close 2025-07-31 09:26:59 +02:00
David Perl
4bbb8fa519 fix: display short lists in SignalDisplay 2025-07-31 09:26:59 +02:00
semantic-release
a972369a72 2.33.1
Automatically generated by python-semantic-release
2025-07-31 06:50:30 +00:00
cd81e7f9ba fix(cli): ensure guis are not started twice 2025-07-31 08:49:48 +02:00
semantic-release
e2b8118f67 2.33.0
Automatically generated by python-semantic-release
2025-07-29 13:24:20 +00:00
5f925ba4e3 build: update bec and qtmonaco min dependencies 2025-07-29 15:23:36 +02:00
fc68d2cf2d feat(monaco): add insert, delete and lsp header 2025-07-29 15:23:36 +02:00
627b49b33a feat(monaco): add vim mode 2025-07-29 15:23:36 +02:00
a51ef04cdf fix(monaco): forward text changed signal 2025-07-29 15:23:36 +02:00
40f4bce285 test(web console): add tests for the web console 2025-07-29 15:23:36 +02:00
2b9fe6c959 feat(web console): add signal to indicate when the js backend is initialized 2025-07-29 15:23:36 +02:00
c2e16429c9 feat(web console): add set_readonly method 2025-07-29 15:23:36 +02:00
semantic-release
85ce2aa136 2.32.0
Automatically generated by python-semantic-release
2025-07-29 13:09:07 +00:00
fd5af01842 feat(dock area): add screenshot toolbar action 2025-07-29 15:08:17 +02:00
8a214c8978 feat(rpc_timeout): add decorator to override the rpc timeout 2025-07-29 15:08:17 +02:00
124 changed files with 10004 additions and 502 deletions

View File

@@ -57,6 +57,14 @@ jobs:
id: coverage
run: pytest --random-order --cov=bec_widgets --cov-config=pyproject.toml --cov-branch --cov-report=xml --no-cov-on-fail tests/unit_tests/
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
name: image-references
path: bec_widgets/tests/reference_failures/
if-no-files-found: ignore
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:

View File

@@ -1,6 +1,158 @@
# 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
- **monaco**: Forward text changed signal
([`a51ef04`](https://github.com/bec-project/bec_widgets/commit/a51ef04cdf0ac8abdb7008d78b13c75b86ce9e06))
### Build System
- Update bec and qtmonaco min dependencies
([`5f925ba`](https://github.com/bec-project/bec_widgets/commit/5f925ba4e3840219e4473d6346ece6746076f718))
### Features
- **monaco**: Add insert, delete and lsp header
([`fc68d2c`](https://github.com/bec-project/bec_widgets/commit/fc68d2cf2d6b161d8e3b9fc9daf6185d9197deba))
- **monaco**: Add vim mode
([`627b49b`](https://github.com/bec-project/bec_widgets/commit/627b49b33a30e45b2bfecb57f090eecfa31af09d))
- **web console**: Add set_readonly method
([`c2e1642`](https://github.com/bec-project/bec_widgets/commit/c2e16429c91de7cc0e672ba36224e9031c1c4234))
- **web console**: Add signal to indicate when the js backend is initialized
([`2b9fe6c`](https://github.com/bec-project/bec_widgets/commit/2b9fe6c9590c8d18b7542307273176e118828681))
### Testing
- **web console**: Add tests for the web console
([`40f4bce`](https://github.com/bec-project/bec_widgets/commit/40f4bce2854bcf333ce261229bd1703b80ced538))
## v2.32.0 (2025-07-29)
### Features
- **dock area**: Add screenshot toolbar action
([`fd5af01`](https://github.com/bec-project/bec_widgets/commit/fd5af0184279400ca6d8e5d2042f31be88d180f3))
- **rpc_timeout**: Add decorator to override the rpc timeout
([`8a214c8`](https://github.com/bec-project/bec_widgets/commit/8a214c897899d0d94d5f262591a001c127d1b155))
## v2.31.3 (2025-07-29)
### Bug Fixes

View File

@@ -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"]

View File

@@ -12,7 +12,7 @@ from typing import Literal, Optional
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module
logger = bec_logger.logger
@@ -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
@@ -414,6 +507,13 @@ class BECDockArea(RPCBase):
dict: The state of the dock area.
"""
@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.
"""
@rpc_call
def restore_state(
self, state: "dict" = None, missing: "Literal['ignore', 'error']" = "ignore", extra="bottom"
@@ -435,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."""
@@ -518,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."""
@@ -534,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."""
@@ -995,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."""
@@ -1005,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."""
@@ -1038,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."""
@@ -1426,6 +1605,25 @@ 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):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def color_map(self) -> "str":
@@ -1964,6 +2162,25 @@ 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):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def color_map(self) -> "str":
@@ -2448,6 +2665,26 @@ class MonacoWidget(RPCBase):
Get the current text from the Monaco editor.
"""
@rpc_call
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
Args:
text (str): The text to insert.
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
"""
@rpc_call
def delete_line(self, line: int | None = None) -> None:
"""
Delete a line in the Monaco editor.
Args:
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
"""
@rpc_call
def set_language(self, language: str) -> None:
"""
@@ -2521,6 +2758,53 @@ class MonacoWidget(RPCBase):
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
"""
@rpc_call
def set_vim_mode_enabled(self, enabled: bool) -> None:
"""
Enable or disable Vim mode in the Monaco editor.
Args:
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
"""
@rpc_call
def set_lsp_header(self, header: str) -> None:
"""
Set the LSP (Language Server Protocol) header for the Monaco editor.
The header is used to provide context for language servers but is not displayed in the editor.
Args:
header (str): The LSP header to set.
"""
@rpc_call
def get_lsp_header(self) -> str:
"""
Get the current LSP header set in the Monaco editor.
Returns:
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."""
@@ -2796,6 +3080,25 @@ 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):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def color(self) -> "tuple":
@@ -3201,6 +3504,25 @@ 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):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def highlighted_index(self):
@@ -3415,6 +3737,25 @@ 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):
"""
Take a screenshot of the dock area and save it to a file.
"""
class PositionerBox2D(RPCBase):
"""Simple Widget to control two positioners in box form"""
@@ -3437,6 +3778,25 @@ 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):
"""
Take a screenshot of the dock area and save it to a file.
"""
class PositionerControlLine(RPCBase):
"""A widget that controls a single device."""
@@ -3450,6 +3810,25 @@ 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):
"""
Take a screenshot of the dock area and save it to a file.
"""
class PositionerGroup(RPCBase):
"""Simple Widget to control a positioner in box form"""
@@ -3462,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."""
@@ -3601,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."""
@@ -3611,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
@@ -3892,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."""
@@ -3903,9 +4344,22 @@ 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)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
@@ -3918,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."""
@@ -4216,6 +4682,25 @@ 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):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def main_curve(self) -> "ScatterCurve":
@@ -4445,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."""
@@ -4497,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"""
@@ -4529,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":
@@ -5074,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"""
@@ -5113,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.
"""

View 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:

View File

@@ -53,7 +53,7 @@ from __future__ import annotations
{base_imports}
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
@@ -180,7 +180,10 @@ class {class_name}(RPCBase):"""
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
)
if hasattr(obj, "__rpc_timeout__"):
timeout = {"value": obj.__rpc_timeout__}
else:
timeout = {}
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
@@ -205,14 +208,26 @@ class {class_name}(RPCBase):"""
def {method}{str(sig_overload)}: ...
"""
self.content += """
@rpc_call"""
self.content += f"""
{self._rpc_call(timeout)}"""
self.content += f"""
def {method}{str(sig)}:
\"\"\"
{doc}
\"\"\""""
def _rpc_call(self, timeout_info: dict[str, float | None]):
"""
Decorator to mark a method as an RPC call.
This is used to generate the client code for the method.
"""
if not timeout_info:
return "@rpc_call"
timeout = timeout_info.get("value", None)
return f"""
@rpc_timeout({timeout})
@rpc_call"""
def write(self, file_name: str):
"""
Write the content to a file, automatically formatted with black.

View File

@@ -39,6 +39,29 @@ def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
def rpc_timeout(timeout):
"""
A decorator to set a timeout for an RPC call.
Args:
timeout: The timeout in seconds.
Returns:
The decorated function.
"""
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = timeout
return func(self, *args, **kwargs)
return wrapper
return decorator
def rpc_call(func):
"""
A decorator for calling a function on the server.

View File

@@ -7,8 +7,10 @@ import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
import darkdetect
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_qthemes import apply_theme
from qtmonaco.pylsp_provider import pylsp_server
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
@@ -92,6 +94,11 @@ class GUIServer:
Run the GUI server.
"""
self.app = QApplication(sys.argv)
if darkdetect.isDark():
apply_theme("dark")
else:
apply_theme("light")
self.app.setApplicationName("BEC")
self.app.gui_id = self.gui_id # type: ignore
self.setup_bec_icon()

View File

@@ -0,0 +1,67 @@
from qtpy import QtCore, QtWidgets
from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
class BECMainApp(QtWidgets.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# Main layout
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Tab widget as central area
self.tabs = QtWidgets.QTabWidget(self)
self.tabs.setContentsMargins(0, 0, 0, 0)
self.tabs.setTabPosition(QtWidgets.QTabWidget.West) # Tabs on the left side
layout.addWidget(self.tabs)
# Add DM
self._add_device_manager_view()
# Add Plot area
self._add_ad_dockarea()
# Adjust size of tab bar
# TODO not yet properly working, tabs a spread across the full length, to be checked!
tab_bar = self.tabs.tabBar()
tab_bar.setFixedWidth(tab_bar.sizeHint().width())
def _add_device_manager_view(self) -> None:
self.device_manager_view = DeviceManagerView(parent=self)
self.add_tab(self.device_manager_view, "Device Manager")
def _add_ad_dockarea(self) -> None:
self.advanced_dock_area = AdvancedDockArea(parent=self)
self.add_tab(self.advanced_dock_area, "Plot Area")
def add_tab(self, widget: QtWidgets.QWidget, title: str):
"""Add a custom QWidget as a tab."""
tab_container = QtWidgets.QWidget()
tab_layout = QtWidgets.QVBoxLayout(tab_container)
tab_layout.setContentsMargins(0, 0, 0, 0)
tab_layout.setSpacing(0)
tab_layout.addWidget(widget)
self.tabs.addTab(tab_container, title)
if __name__ == "__main__":
import sys
from bec_lib.bec_yaml_loader import yaml_load
from bec_qthemes import apply_theme
app = QtWidgets.QApplication(sys.argv)
apply_theme("light")
win = BECMainApp()
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
cfg = yaml_load(config_path)
cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
win.device_manager_view.device_table_view.set_device_config(cfg)
win.resize(1920, 1080)
win.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,491 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING, List
import PySide6QtAds as QtAds
import yaml
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.file_utils import DeviceConfigWriter
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from bec_qthemes import apply_theme
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QFileDialog, QMessageBox, QSplitter, QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.control.device_manager.components import (
DeviceTableView,
DMConfigView,
DMOphydTest,
DocstringView,
)
from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import (
AvailableDeviceResources,
)
if TYPE_CHECKING:
from bec_lib.client import BECClient
logger = bec_logger.logger
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
"""
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
Works for horizontal or vertical splitters and sets matching stretch factors.
"""
def apply():
n = splitter.count()
if n == 0:
return
w = list(weights[:n]) + [1] * max(0, n - len(weights))
w = [max(0.0, float(x)) for x in w]
tot_w = sum(w)
if tot_w <= 0:
w = [1.0] * n
tot_w = float(n)
total_px = (
splitter.width() if splitter.orientation() == Qt.Horizontal else splitter.height()
)
if total_px < 2:
QTimer.singleShot(0, apply)
return
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
diff = total_px - sum(sizes)
if diff != 0:
idx = max(range(n), key=lambda i: w[i])
sizes[idx] = max(1, sizes[idx] + diff)
splitter.setSizes(sizes)
for i, wi in enumerate(w):
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
QTimer.singleShot(0, apply)
class DeviceManagerView(BECWidget, QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, client=None, *args, **kwargs)
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
self.dock_manager = CDockManager(self)
self._root_layout.addWidget(self.dock_manager)
# Available Resources Widget
self.available_devices = AvailableDeviceResources(self)
self.available_devices_dock = QtAds.CDockWidget("Available Devices", self)
self.available_devices_dock.setWidget(self.available_devices)
# Device Table View widget
self.device_table_view = DeviceTableView(self)
self.device_table_view_dock = QtAds.CDockWidget("Device Table", self)
self.device_table_view_dock.setWidget(self.device_table_view)
# Device Config View widget
self.dm_config_view = DMConfigView(self)
self.dm_config_view_dock = QtAds.CDockWidget("Device Config View", self)
self.dm_config_view_dock.setWidget(self.dm_config_view)
# Docstring View
self.dm_docs_view = DocstringView(self)
self.dm_docs_view_dock = QtAds.CDockWidget("Docstring View", self)
self.dm_docs_view_dock.setWidget(self.dm_docs_view)
# Ophyd Test view
self.ophyd_test_view = DMOphydTest(self)
self.ophyd_test_dock_view = QtAds.CDockWidget("Ophyd Test View", self)
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
# Arrange widgets within the QtAds dock manager
# Central widget area
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea,
self.dm_docs_view_dock,
self.central_dock_area,
)
# Left Area
self.left_dock_area = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock
)
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_config_view_dock, self.left_dock_area
)
# Right area
self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea, self.ophyd_test_dock_view
)
for dock in self.dock_manager.dockWidgets():
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
dock.setFeature(CDockWidget.DockWidgetClosable, False)
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetMovable, False)
# Fetch all dock areas of the dock widgets (on our case always one dock area)
for dock in self.dock_manager.dockWidgets():
area = dock.dockAreaWidget()
area.titleBar().setVisible(False)
# Apply stretch after the layout is done
self.set_default_view([2, 8, 2], [3, 1])
# self.set_default_view([2, 8, 2], [2, 2, 4])
# Connect slots
self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config)
self.device_table_view.selected_device.connect(self.dm_docs_view.on_select_config)
self.ophyd_test_view.device_validated.connect(
self.device_table_view.update_device_validation
)
self.device_table_view.device_configs_added.connect(self.ophyd_test_view.add_device_configs)
self._add_toolbar()
def _add_toolbar(self):
self.toolbar = ModularToolBar(self)
# Add IO actions
self._add_io_actions()
self._add_table_actions()
self.toolbar.show_bundles(["IO", "Table"])
self._root_layout.insertWidget(0, self.toolbar)
def _add_io_actions(self):
# Create IO bundle
io_bundle = ToolbarBundle("IO", self.toolbar.components)
# Add load config from plugin dir
self.toolbar.add_bundle(io_bundle)
load = MaterialIconAction(
icon_name="file_open", parent=self, tooltip="Load configuration file from disk"
)
self.toolbar.components.add_safe("load", load)
load.action.triggered.connect(self._load_file_action)
io_bundle.add_action("load")
# Add safe to disk
safe_to_disk = MaterialIconAction(
icon_name="file_save", parent=self, tooltip="Save config to disk"
)
self.toolbar.components.add_safe("safe_to_disk", safe_to_disk)
safe_to_disk.action.triggered.connect(self._safe_to_disk_action)
io_bundle.add_action("safe_to_disk")
# Add load config from redis
load_redis = MaterialIconAction(
icon_name="cached", parent=self, tooltip="Load current config from Redis"
)
load_redis.action.triggered.connect(self._load_redis_action)
self.toolbar.components.add_safe("load_redis", load_redis)
io_bundle.add_action("load_redis")
# Update config action
update_config_redis = MaterialIconAction(
icon_name="cloud_upload", parent=self, tooltip="Update current config in Redis"
)
update_config_redis.action.triggered.connect(self._update_redis_action)
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
io_bundle.add_action("update_config_redis")
# Table actions
def _add_table_actions(self) -> None:
table_bundle = ToolbarBundle("Table", self.toolbar.components)
# Add load config from plugin dir
self.toolbar.add_bundle(table_bundle)
# Reset composed view
reset_composed = MaterialIconAction(
icon_name="delete_sweep", parent=self, tooltip="Reset current composed config view"
)
reset_composed.action.triggered.connect(self._reset_composed_view)
self.toolbar.components.add_safe("reset_composed", reset_composed)
table_bundle.add_action("reset_composed")
# Add device
add_device = MaterialIconAction(icon_name="add", parent=self, tooltip="Add new device")
add_device.action.triggered.connect(self._add_device_action)
self.toolbar.components.add_safe("add_device", add_device)
table_bundle.add_action("add_device")
# Remove device
remove_device = MaterialIconAction(icon_name="remove", parent=self, tooltip="Remove device")
remove_device.action.triggered.connect(self._remove_device_action)
self.toolbar.components.add_safe("remove_device", remove_device)
table_bundle.add_action("remove_device")
# Rerun validation
rerun_validation = MaterialIconAction(
icon_name="checklist", parent=self, tooltip="Run device validation on selected devices"
)
rerun_validation.action.triggered.connect(self._rerun_validation_action)
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
table_bundle.add_action("rerun_validation")
# Most likly, no actions on available devices
# Actions (vielleicht bundle fuer available devices )
# - reset composed view
# - add new device (EpicsMotor, EpicsMotorECMC, EpicsSignal, CustomDevice)
# - remove device
# - rerun validation (with/without connect)
# IO actions
@SafeSlot()
def _load_file_action(self):
"""Action for the 'load' action to load a config from disk for the io_bundle of the toolbar."""
# Check if plugin repo is installed...
try:
plugin_path = plugin_repo_path()
plugin_name = plugin_package_name()
config_path = os.path.join(plugin_path, plugin_name, "device_configs")
except ValueError:
# Get the recovery config path as fallback
config_path = self._get_recovery_config_path()
logger.warning(
f"No plugin repository installed, fallback to recovery config path: {config_path}"
)
# Implement the file loading logic here
start_dir = os.path.abspath(config_path)
file_path, _ = QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
if file_path:
try:
config = yaml_load(file_path)
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
self.device_table_view.set_device_config(
config
) # TODO ADD QDialog with 'replace', 'add' & 'cancel'
# TODO would we ever like to add the current config to an existing composition
@SafeSlot()
def _load_redis_action(self):
"""Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar."""
reply = QMessageBox.question(
self,
"Load currently active config",
"Do you really want to flush the current config and reload?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
cfg = {}
config_list = self.client.device_manager._get_redis_device_config()
for item in config_list:
k = item["name"]
item.pop("name")
cfg[k] = item
self.device_table_view.set_device_config(cfg)
else:
return
@SafeSlot()
def _safe_to_disk_action(self):
"""Action for the 'safe_to_disk' action to save the current config to disk."""
# Check if plugin repo is installed...
try:
config_path = self._get_recovery_config_path()
except ValueError:
# Get the recovery config path as fallback
config_path = os.path.abspath(os.path.expanduser("~"))
logger.warning(f"Failed to find recovery config path, fallback to: {config_path}")
# Implement the file loading logic here
file_path, _ = QFileDialog.getSaveFileName(
self, caption="Save Config File", dir=config_path
)
if file_path:
config = self.device_table_view.get_device_config()
with open(file_path, "w") as file:
file.write(yaml.dump(config))
# TODO add here logic, should be asyncronous, but probably block UI, and show a loading spinner. If failed, it should report..
@SafeSlot()
def _update_redis_action(self):
"""Action for the 'update_redis' action to update the current config in Redis."""
config = self.device_table_view.get_device_config()
reply = QMessageBox.question(
self,
"Not implemented yet",
"This feature has not been implemented yet, will be coming soon...!!",
QMessageBox.Cancel,
QMessageBox.Cancel,
)
# Table actions
@SafeSlot()
def _reset_composed_view(self):
"""Action for the 'reset_composed_view' action to reset the composed view."""
reply = QMessageBox.question(
self,
"Clear View",
"You are about to clear the current composed config view, please confirm...",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.device_table_view.clear_device_configs()
# TODO Here we would like to implement a custom popup view, that allows to add new devices
# We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device
# For all default Epics devices, we would like to preselect relevant fields, and prompt them with the proper deviceConfig args already, i.e. 'prefix', 'read_pv', 'write_pv' etc..
# For custom Device, they should receive all options. It might be cool to get a side panel with docstring view of the class upon inspecting it to make it easier in case deviceConfig entries are required..
@SafeSlot()
def _add_device_action(self):
"""Action for the 'add_device' action to add a new device."""
# Implement the logic to add a new device
reply = QMessageBox.question(
self,
"Not implemented yet",
"This feature has not been implemented yet, will be coming soon...!!",
QMessageBox.Cancel,
QMessageBox.Cancel,
)
# TODO fix the device table remove actions. This is currently not working properly...
@SafeSlot()
def _remove_device_action(self):
"""Action for the 'remove_device' action to remove a device."""
reply = QMessageBox.question(
self,
"Not implemented yet",
"This feature has not been implemented yet, will be coming soon...!!",
QMessageBox.Cancel,
QMessageBox.Cancel,
)
# TODO implement proper logic for validation. We should also carefully review how these jobs update the table, and how we can cancel pending validations
# in case they are no longer relevant. We might want to 'block' the interactivity on the items for which validation runs with 'connect'!
@SafeSlot()
def _rerun_validation_action(self):
"""Action for the 'rerun_validation' action to rerun validation on selected devices."""
# Implement the logic to rerun validation on selected devices
reply = QMessageBox.question(
self,
"Not implemented yet",
"This feature has not been implemented yet, will be coming soon...!!",
QMessageBox.Cancel,
QMessageBox.Cancel,
)
####### Default view has to be done with setting up splitters ########
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
"""Apply initial weights to every horizontal and vertical splitter.
Examples:
horizontal_weights = [1, 3, 2, 1]
vertical_weights = [3, 7] # top:bottom = 30:70
"""
splitters_h = []
splitters_v = []
for splitter in self.findChildren(QSplitter):
if splitter.orientation() == Qt.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.Vertical:
splitters_v.append(splitter)
def apply_all():
for s in splitters_h:
set_splitter_weights(s, horizontal_weights)
for s in splitters_v:
set_splitter_weights(s, vertical_weights)
QTimer.singleShot(0, apply_all)
def set_stretch(self, *, horizontal=None, vertical=None):
"""Update splitter weights and re-apply to all splitters.
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
for convenience: horizontal roles = {"left","center","right"},
vertical roles = {"top","bottom"}.
"""
def _coerce_h(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [
float(x.get("left", 1)),
float(x.get("center", x.get("middle", 1))),
float(x.get("right", 1)),
]
return None
def _coerce_v(x):
if x is None:
return None
if isinstance(x, (list, tuple)):
return list(map(float, x))
if isinstance(x, dict):
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
return None
h = _coerce_h(horizontal)
v = _coerce_v(vertical)
if h is None:
h = [1, 1, 1]
if v is None:
v = [1, 1]
self.set_default_view(h, v)
def _get_recovery_config_path(self) -> str:
"""Get the recovery config path from the log_writer config."""
# pylint: disable=protected-access
log_writer_config: BECClient = self.client._service_config.config.get("log_writer", {})
writer = DeviceConfigWriter(service_config=log_writer_config)
return os.path.abspath(os.path.expanduser(writer.get_recovery_directory()))
if __name__ == "__main__":
import sys
from copy import deepcopy
from bec_lib.bec_yaml_loader import yaml_load
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
app = QApplication(sys.argv)
w = QWidget()
l = QVBoxLayout()
w.setLayout(l)
apply_theme("dark")
button = DarkModeButton()
l.addWidget(button)
device_manager_view = DeviceManagerView()
l.addWidget(device_manager_view)
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
cfg = yaml_load(config_path)
cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
# config = device_manager_view.client.device_manager._get_redis_device_config()
device_manager_view.device_table_view.set_device_config(cfg)
w.show()
w.setWindowTitle("Device Manager View")
w.resize(1920, 1080)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -0,0 +1,110 @@
"""Top Level wrapper for device_manager widget"""
from __future__ import annotations
import os
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy import QtCore, QtWidgets
from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class DeviceManagerWidget(BECWidget, QtWidgets.QWidget):
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent)
self.stacked_layout = QtWidgets.QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
self.setLayout(self.stacked_layout)
# Add device manager view
self.device_manager_view = DeviceManagerView()
self.stacked_layout.addWidget(self.device_manager_view)
# Add overlay widget
self._overlay_widget = QtWidgets.QWidget(self)
self._customize_overlay()
self.stacked_layout.addWidget(self._overlay_widget)
self.stacked_layout.setCurrentWidget(self._overlay_widget)
def _customize_overlay(self):
self._overlay_widget.setStyleSheet(
"background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);"
)
self._overlay_widget.setAutoFillBackground(True)
self._overlay_layout = QtWidgets.QVBoxLayout()
self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setLayout(self._overlay_layout)
self._overlay_widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
)
# Load current config
self.button_load_current_config = QtWidgets.QPushButton("Load Current Config")
icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False)
self.button_load_current_config.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_current_config)
self.button_load_current_config.clicked.connect(self._load_config_clicked)
# Load config from disk
self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File")
icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False)
self.button_load_config_from_file.setIcon(icon)
self._overlay_layout.addWidget(self.button_load_config_from_file)
self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked)
self._overlay_widget.setVisible(True)
def _load_config_from_file_clicked(self):
"""Handle click on 'Load Config From File' button."""
start_dir = os.path.expanduser("~")
file_path, _ = QtWidgets.QFileDialog.getOpenFileName(
self, caption="Select Config File", dir=start_dir
)
if file_path:
self._load_config_from_file(file_path)
def _load_config_from_file(self, file_path: str):
try:
config = yaml_load(file_path)
except Exception as e:
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
return
config_list = []
for name, cfg in config.items():
config_list.append(cfg)
config_list[-1]["name"] = name
self.device_manager_view.device_table_view.set_device_config(config_list)
# self.device_manager_view.ophyd_test.on_device_config_update(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
@SafeSlot()
def _load_config_clicked(self):
"""Handle click on 'Load Current Config' button."""
config = self.client.device_manager._get_redis_device_config()
config.append({"name": "wrong_device", "some_value": 1})
self.device_manager_view.device_table_view.set_device_config(config)
# self.device_manager_view.ophyd_test.on_device_config_update(config)
self.stacked_layout.setCurrentWidget(self.device_manager_view)
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
device_manager = DeviceManagerWidget()
# config = device_manager.client.device_manager._get_redis_device_config()
# device_manager.device_table_view.set_device_config(config)
device_manager.show()
device_manager.setWindowTitle("Device Manager View")
device_manager.resize(1600, 1200)
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
sys.exit(app.exec_())

View File

@@ -15,7 +15,9 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.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 +46,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 +123,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)
@@ -169,6 +170,7 @@ if __name__ == "__main__": # pragma: no cover
module_path = os.path.dirname(bec_widgets.__file__)
app = QApplication(sys.argv)
apply_theme("dark")
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)

View File

@@ -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:
"""

View File

@@ -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)

View 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)

View 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...")

View File

@@ -1,16 +1,19 @@
from __future__ import annotations
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, Slot
from qtpy.QtWidgets import QApplication
from qtpy.QtCore import QObject
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeConnect, SafeSlot
from bec_widgets.utils.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
@@ -24,7 +27,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__(
@@ -42,8 +45,7 @@ class BECWidget(BECConnector):
>>> class MyWidget(BECWidget, QWidget):
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
>>> super().__init__(client=client, config=config, gui_id=gui_id)
>>> QWidget.__init__(self, parent=parent)
>>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id)
Args:
@@ -59,15 +61,6 @@ class BECWidget(BECConnector):
)
if not isinstance(self, QObject):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
# Instead, we will set the theme to the system setting on startup
if darkdetect.isDark():
set_theme("dark")
else:
set_theme("light")
if theme_update:
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
self._connect_to_theme_change()
@@ -75,9 +68,11 @@ class BECWidget(BECConnector):
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
if hasattr(qapp, "theme"):
SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
@SafeSlot(str)
@SafeSlot()
def _update_theme(self, theme: str | None = None):
"""Update the theme."""
if theme is None:
@@ -88,7 +83,7 @@ class BECWidget(BECConnector):
theme = "dark"
self.apply_theme(theme)
@Slot(str)
@SafeSlot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
@@ -97,6 +92,50 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied.
"""
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
def screenshot(self, file_name: str | None = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
if not isinstance(self, QWidget):
logger.error("Cannot take screenshot of non-QWidget instance")
return
screenshot = self.grab()
if file_name is None:
file_name, _ = QFileDialog.getSaveFileName(
self,
"Save Screenshot",
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
)
if not file_name:
return
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():

View File

@@ -3,11 +3,11 @@ from __future__ import annotations
import re
from typing import TYPE_CHECKING, Literal
import bec_qthemes
import numpy as np
import pyqtgraph as pg
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
from bec_qthemes import apply_theme as apply_theme_global
from pydantic_core import PydanticCustomError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
@@ -23,7 +23,10 @@ def get_theme_name():
def get_theme_palette():
return bec_qthemes.load_palette(get_theme_name())
# FIXME this is legacy code, should be removed in the future
app = QApplication.instance()
palette = app.palette()
return palette
def get_accent_colors() -> AccentColors | None:
@@ -36,105 +39,18 @@ def get_accent_colors() -> AccentColors | None:
return QApplication.instance().theme.accent_colors
def _theme_update_callback():
"""
Internal callback function to update the theme based on the system theme.
"""
app = QApplication.instance()
# pylint: disable=protected-access
app.theme.theme = app.os_listener._theme.lower()
app.theme_signal.theme_updated.emit(app.theme.theme)
apply_theme(app.os_listener._theme.lower())
def set_theme(theme: Literal["dark", "light", "auto"]):
"""
Set the theme for the application.
Args:
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
"""
app = QApplication.instance()
bec_qthemes.setup_theme(theme, install_event_filter=False)
app.theme_signal.theme_updated.emit(theme)
apply_theme(theme)
if theme != "auto":
return
if not hasattr(app, "os_listener") or app.os_listener is None:
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
app.installEventFilter(app.os_listener)
def process_all_deferred_deletes(qapp):
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
qapp.processEvents(QEventLoop.AllEvents)
def apply_theme(theme: Literal["dark", "light"]):
"""
Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead.
Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally.
"""
app = QApplication.instance()
graphic_layouts = [
child
for top in app.topLevelWidgets()
for child in top.findChildren(pg.GraphicsLayoutWidget)
]
plot_items = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.PlotItem)
]
histograms = [
item
for gl in graphic_layouts
for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items
if isinstance(item, pg.HistogramLUTItem)
]
# Update background color based on the theme
if theme == "light":
background_color = "#e9ecef" # Subtle contrast for light mode
foreground_color = "#141414"
label_color = "#000000"
axis_color = "#666666"
else:
background_color = "#141414" # Dark mode
foreground_color = "#e9ecef"
label_color = "#FFFFFF"
axis_color = "#CCCCCC"
# update GraphicsLayoutWidget
pg.setConfigOptions(foreground=foreground_color, background=background_color)
for pg_widget in graphic_layouts:
pg_widget.setBackground(background_color)
# update PlotItems
for plot_item in plot_items:
for axis in ["left", "right", "top", "bottom"]:
plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color))
plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color))
# Change title color
plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color)
# Change legend color
if hasattr(plot_item, "legend") and plot_item.legend is not None:
plot_item.legend.setLabelTextColor(label_color)
# if legend is in plot item and theme is changed, has to be like that because of pg opt logic
for sample, label in plot_item.legend.items:
label_text = label.text
label.setText(label_text, color=label_color)
# update HistogramLUTItem
for histogram in histograms:
histogram.axis.setPen(pg.mkPen(color=axis_color))
histogram.axis.setTextPen(pg.mkPen(color=label_color))
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)
app.setStyleSheet(style)
process_all_deferred_deletes(QApplication.instance())
apply_theme_global(theme)
process_all_deferred_deletes(QApplication.instance())
class Colors:

View File

@@ -11,6 +11,7 @@ from qtpy.QtWidgets import (
QPushButton,
QSizePolicy,
QSpacerItem,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -122,15 +123,14 @@ class CompactPopupWidget(QWidget):
self.compact_view_widget = QWidget(self)
self.compact_view_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
QHBoxLayout(self.compact_view_widget)
self.compact_view_widget.layout().setSpacing(0)
self.compact_view_widget.layout().setSpacing(5)
self.compact_view_widget.layout().setContentsMargins(0, 0, 0, 0)
self.compact_view_widget.layout().addSpacerItem(
QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)
)
self.compact_label = QLabel(self.compact_view_widget)
self.compact_status = LedLabel(self.compact_view_widget)
self.compact_show_popup = QPushButton(self.compact_view_widget)
self.compact_show_popup.setFlat(True)
self.compact_show_popup = QToolButton(self.compact_view_widget)
self.compact_show_popup.setIcon(
material_icon(icon_name="expand_content", size=(10, 10), convert_to_pixmap=False)
)

View File

@@ -2,7 +2,9 @@ import functools
import sys
import traceback
import shiboken6
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import Property, QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication, QMessageBox, QPushButton, QVBoxLayout, QWidget
@@ -90,6 +92,52 @@ def SafeProperty(prop_type, *prop_args, popup_error: bool = False, default=None,
return decorator
def _safe_connect_slot(weak_instance, weak_slot, *connect_args):
"""Internal function used by SafeConnect to handle weak references to slots."""
instance = weak_instance()
slot_func = weak_slot()
# Check if the python object has already been garbage collected
if instance is None or slot_func is None:
return
# Check if the python object has already been marked for deletion
if getattr(instance, "_destroyed", False):
return
# Check if the C++ object is still valid
if not shiboken6.isValid(instance):
return
if connect_args:
slot_func(*connect_args)
slot_func()
def SafeConnect(instance, signal, slot): # pylint: disable=invalid-name
"""
Method to safely handle Qt signal-slot connections. The python object is only forwarded
as a weak reference to avoid stale objects.
Args:
instance: The instance to connect.
signal: The signal to connect to.
slot: The slot to connect.
Example:
>>> SafeConnect(self, qapp.theme.theme_changed, self._update_theme)
"""
weak_instance = safe_ref(instance)
weak_slot = safe_ref(slot)
# Create a partial function that will check weak references before calling the actual slot
safe_slot = functools.partial(_safe_connect_slot, weak_instance, weak_slot)
# Connect the signal to the safe connect slot wrapper
return signal.connect(safe_slot)
def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name
"""Function with args, acting like a decorator, applying "error_managed" decorator + Qt Slot
to the passed function, to display errors instead of potentially raising an exception

View 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())

View File

@@ -1,11 +1,12 @@
import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtCore import Property, Qt
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class RoundedFrame(QFrame):
# TODO this should be removed completely in favor of QSS styling, no time now
"""
A custom QFrame with rounded corners and optional theme updates.
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
@@ -28,6 +29,9 @@ class RoundedFrame(QFrame):
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
# Ensure QSS can paint background/border on this widget
self.setAttribute(Qt.WA_StyledBackground, True)
# Create a layout for the frame
if orientation == "vertical":
self.layout = QVBoxLayout(self)
@@ -45,22 +49,10 @@ class RoundedFrame(QFrame):
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
self.apply_plot_widget_style()
self.update_style()
def apply_theme(self, theme: str):
"""
Apply the theme to the frame and its content if theme updates are enabled.
"""
if self.content_widget is not None and isinstance(
self.content_widget, pg.GraphicsLayoutWidget
):
self.content_widget.setBackground(self.background_color)
# Update background color based on the theme
if theme == "light":
self.background_color = "#e9ecef" # Subtle contrast for light mode
else:
self.background_color = "#141414" # Dark mode
"""Deprecated: RoundedFrame no longer handles theme; styling is QSS-driven."""
self.update_style()
@Property(int)
@@ -77,34 +69,21 @@ class RoundedFrame(QFrame):
"""
Update the style of the frame based on the background color.
"""
if self.background_color:
self.setStyleSheet(
f"""
self.setStyleSheet(
f"""
QFrame#roundedFrame {{
background-color: {self.background_color};
border-radius: {self._radius}; /* Rounded corners */
border-radius: {self._radius}px;
}}
"""
)
)
self.apply_plot_widget_style()
def apply_plot_widget_style(self, border: str = "none"):
"""
Automatically apply background, border, and axis styles to the PlotWidget.
Args:
border (str): Border style (e.g., 'none', '1px solid red').
Let QSS/pyqtgraph handle plot styling; avoid overriding here.
"""
if isinstance(self.content_widget, pg.GraphicsLayoutWidget):
# Apply border style via stylesheet
self.content_widget.setStyleSheet(
f"""
GraphicsLayoutWidget {{
border: {border}; /* Explicitly set the border */
}}
"""
)
self.content_widget.setBackground(self.background_color)
self.content_widget.setStyleSheet("")
class ExampleApp(QWidget): # pragma: no cover
@@ -128,24 +107,14 @@ class ExampleApp(QWidget): # pragma: no cover
plot_item_2.plot([1, 2, 4, 8, 16, 32], pen="r")
plot2.plot_item = plot_item_2
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
# Add to layout
# Add to layout (no RoundedFrame wrapper; QSS styles plots)
layout.addWidget(dark_button)
layout.addWidget(rounded_plot1)
layout.addWidget(rounded_plot2)
layout.addWidget(plot1)
layout.addWidget(plot2)
self.setLayout(layout)
from qtpy.QtCore import QTimer
def change_theme():
rounded_plot1.apply_theme("light")
rounded_plot2.apply_theme("dark")
QTimer.singleShot(100, change_theme)
# Theme flip demo removed; global theming applies automatically
if __name__ == "__main__": # pragma: no cover

View File

@@ -13,3 +13,17 @@ def register_rpc_methods(cls):
if getattr(method, "rpc_public", False):
cls.USER_ACCESS.add(name)
return cls
def rpc_timeout(timeout: float | None):
"""
Decorator to set a timeout for RPC methods.
The actual implementation of timeout handling is within the cli module. This decorator
is solely to inform the generate-cli command about the timeout value.
"""
def decorator(func):
func.__rpc_timeout__ = timeout # Store the timeout value in the function
return func
return decorator

View File

@@ -1,3 +1,6 @@
from typing import Type
from bec_lib.codecs import BECCodec
from bec_lib.serialization import msgpack
from qtpy.QtCore import QPointF
@@ -6,39 +9,26 @@ def register_serializer_extension():
"""
Register the serializer extension for the BECConnector.
"""
if not module_is_registered("bec_widgets.utils.serialization"):
msgpack.register_object_hook(encode_qpointf, decode_qpointf)
if not msgpack.is_registered(QPointF):
msgpack.register_codec(QPointFEncoder)
def module_is_registered(module_name: str) -> bool:
"""
Check if the module is registered in the encoder.
class QPointFEncoder(BECCodec):
obj_type: Type = QPointF
Args:
module_name (str): The name of the module to check.
@staticmethod
def encode(obj: QPointF) -> str:
"""
Encode a QPointF object to a list of floats. As this is mostly used for sending
data to the client, it is not necessary to convert it back to a QPointF object.
"""
if isinstance(obj, QPointF):
return [obj.x(), obj.y()]
return obj
Returns:
bool: True if the module is registered, False otherwise.
"""
# pylint: disable=protected-access
for enc in msgpack._encoder:
if enc[0].__module__ == module_name:
return True
return False
def encode_qpointf(obj):
"""
Encode a QPointF object to a list of floats. As this is mostly used for sending
data to the client, it is not necessary to convert it back to a QPointF object.
"""
if isinstance(obj, QPointF):
return [obj.x(), obj.y()]
return obj
def decode_qpointf(obj):
"""
no-op function since QPointF is encoded as a list of floats.
"""
return obj
@staticmethod
def decode(type_name: str, data: list[float]) -> list[float]:
"""
no-op function since QPointF is encoded as a list of floats.
"""
return data

View File

@@ -446,6 +446,8 @@ class ExpandableMenuAction(ToolBarAction):
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
button = QToolButton(toolbar)
button.setObjectName("toolbarMenuButton")
button.setAutoRaise(True)
if self.icon_path:
button.setIcon(QIcon(self.icon_path))
button.setText(self.tooltip)

View File

@@ -10,7 +10,7 @@ from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QAction, QColor
from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_name, set_theme
from bec_widgets.utils.colors import apply_theme, get_theme_name
from bec_widgets.utils.toolbars.actions import MaterialIconAction, ToolBarAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
from bec_widgets.utils.toolbars.connections import BundleConnection
@@ -507,7 +507,7 @@ if __name__ == "__main__": # pragma: no cover
self.test_label.setText("FPS Monitor Disabled")
app = QApplication(sys.argv)
set_theme("light")
apply_theme("light")
main_window = MainWindow()
main_window.show()
sys.exit(app.exec_())

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -0,0 +1,911 @@
from __future__ import annotations
import os
from typing import Literal, cast
import PySide6QtAds as QtAds
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Signal
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from shiboken6 import isValid
from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.property_editor import PropertyEditor
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
MaterialIconAction,
WidgetAction,
)
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
SETTINGS_KEYS,
is_profile_readonly,
list_profiles,
open_settings,
profile_path,
read_manifest,
set_profile_readonly,
write_manifest,
)
from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import (
WorkspaceConnection,
workspace_bundle,
)
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class DockSettingsDialog(QDialog):
def __init__(self, parent: QWidget, target: QWidget):
super().__init__(parent)
self.setWindowTitle("Dock Settings")
self.setModal(True)
layout = QVBoxLayout(self)
# Property editor
self.prop_editor = PropertyEditor(target, self, show_only_bec=True)
layout.addWidget(self.prop_editor)
class SaveProfileDialog(QDialog):
"""Dialog for saving workspace profiles with read-only option."""
def __init__(self, parent: QWidget, current_name: str = ""):
super().__init__(parent)
self.setWindowTitle("Save Workspace Profile")
self.setModal(True)
self.resize(400, 150)
layout = QVBoxLayout(self)
# Name input
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Profile Name:"))
self.name_edit = QLineEdit(current_name)
self.name_edit.setPlaceholderText("Enter profile name...")
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# Read-only checkbox
self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)")
layout.addWidget(self.readonly_checkbox)
# Info label
info_label = QLabel("Read-only profiles are protected from modification and deletion.")
info_label.setStyleSheet("color: gray; font-size: 10px;")
layout.addWidget(info_label)
# Buttons
btn_row = QHBoxLayout()
btn_row.addStretch(1)
self.save_btn = QPushButton("Save")
self.save_btn.setDefault(True)
cancel_btn = QPushButton("Cancel")
self.save_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(self.save_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
# Enable/disable save button based on name input
self.name_edit.textChanged.connect(self._update_save_button)
self._update_save_button()
def _update_save_button(self):
"""Enable save button only when name is not empty."""
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
def get_profile_name(self) -> str:
"""Get the entered profile name."""
return self.name_edit.text().strip()
def is_readonly(self) -> bool:
"""Check if the profile should be marked as read-only."""
return self.readonly_checkbox.isChecked()
class AdvancedDockArea(BECWidget, QWidget):
RPC = True
PLUGIN = False
USER_ACCESS = [
"new",
"widget_map",
"widget_list",
"lock_workspace",
"attach_all",
"delete_all",
"mode",
"mode.setter",
]
# Define a signal for mode changes
mode_changed = Signal(str)
def __init__(
self,
parent=None,
mode: str = "developer",
default_add_direction: Literal["left", "right", "top", "bottom"] = "right",
*args,
**kwargs,
):
super().__init__(parent=parent, *args, **kwargs)
# Title (as a top-level QWidget it can have a window title)
self.setWindowTitle("Advanced Dock Area")
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
# Init Dock Manager
self.dock_manager = CDockManager(self)
self.dock_manager.setStyleSheet("")
# Dock manager helper variables
self._locked = False # Lock state of the workspace
# Initialize mode property first (before toolbar setup)
self._mode = "developer"
self._default_add_direction = (
default_add_direction
if default_add_direction in ("left", "right", "top", "bottom")
else "right"
)
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self._setup_toolbar()
self._hook_toolbar()
# Place toolbar and dock manager into layout
self._root_layout.addWidget(self.toolbar)
self._root_layout.addWidget(self.dock_manager, 1)
# Populate and hook the workspace combo
self._refresh_workspace_list()
# State manager
self.state_manager = WidgetStateManager(self)
# Developer mode state
self._editable = None
# Initialize default editable state based on current lock
self._set_editable(True) # default to editable; will sync toolbar toggle below
# Sync Developer toggle icon state after initial setup
dev_action = self.toolbar.components.get_action("developer_mode").action
dev_action.setChecked(self._editable)
# Apply the requested mode after everything is set up
self.mode = mode
def _make_dock(
self,
widget: QWidget,
*,
closable: bool,
floatable: bool,
movable: bool = True,
area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea,
start_floating: bool = False,
) -> CDockWidget:
dock = CDockWidget(widget.objectName())
dock.setWidget(widget)
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
dock.setFeature(CDockWidget.CustomCloseHandling, True)
dock.setFeature(CDockWidget.DockWidgetClosable, closable)
dock.setFeature(CDockWidget.DockWidgetFloatable, floatable)
dock.setFeature(CDockWidget.DockWidgetMovable, movable)
self._install_dock_settings_action(dock, widget)
def on_dock_close():
widget.close()
dock.closeDockWidget()
dock.deleteDockWidget()
def on_widget_destroyed():
if not isValid(dock):
return
dock.closeDockWidget()
dock.deleteDockWidget()
dock.closeRequested.connect(on_dock_close)
if hasattr(widget, "widget_removed"):
widget.widget_removed.connect(on_widget_destroyed)
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
self.dock_manager.addDockWidget(area, dock)
if start_floating:
dock.setFloating()
return dock
def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None:
action = MaterialIconAction(
icon_name="settings", tooltip="Dock settings", filled=True, parent=self
).action
action.setToolTip("Dock settings")
action.setObjectName("dockSettingsAction")
action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget))
dock.setTitleBarActions([action])
dock.setting_action = action
def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None:
dlg = DockSettingsDialog(self, widget)
dlg.resize(600, 600)
dlg.exec()
def _apply_dock_lock(self, locked: bool) -> None:
if locked:
self.dock_manager.lockDockWidgetFeaturesGlobally()
else:
self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures)
def _delete_dock(self, dock: CDockWidget) -> None:
w = dock.widget()
if w and isValid(w):
w.close()
w.deleteLater()
if isValid(dock):
dock.closeDockWidget()
dock.deleteDockWidget()
def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea:
"""Return ADS DockWidgetArea from a human-friendly direction string.
If *where* is None, fall back to instance default.
"""
d = (where or getattr(self, "_default_add_direction", "right") or "right").lower()
mapping = {
"left": QtAds.DockWidgetArea.LeftDockWidgetArea,
"right": QtAds.DockWidgetArea.RightDockWidgetArea,
"top": QtAds.DockWidgetArea.TopDockWidgetArea,
"bottom": QtAds.DockWidgetArea.BottomDockWidgetArea,
}
return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea)
################################################################################
# Toolbar Setup
################################################################################
def _setup_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
PLOT_ACTIONS = {
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
"scatter_waveform": (
ScatterWaveform.ICON_NAME,
"Add Scatter Waveform",
"ScatterWaveform",
),
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
"image": (Image.ICON_NAME, "Add Image", "Image"),
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
}
DEVICE_ACTIONS = {
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
}
UTIL_ACTIONS = {
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
"vs_code": (VSCodeEditor.ICON_NAME, "Add VS Code", "VSCodeEditor"),
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
"progress_bar": (
RingProgressBar.ICON_NAME,
"Add Circular ProgressBar",
"RingProgressBar",
),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
}
# Create expandable menu actions (original behavior)
def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]):
self.toolbar.components.add_safe(
key,
ExpandableMenuAction(
label=label,
actions={
k: MaterialIconAction(
icon_name=v[0], tooltip=v[1], filled=True, parent=self
)
for k, v in mapping.items()
},
),
)
b = ToolbarBundle(key, self.toolbar.components)
b.add_action(key)
self.toolbar.add_bundle(b)
_build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS)
_build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS)
_build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS)
# Create flat toolbar bundles for each widget type
def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]):
bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components)
for action_id, (icon_name, tooltip, widget_type) in mapping.items():
# Create individual action for each widget type
flat_action_id = f"flat_{action_id}"
self.toolbar.components.add_safe(
flat_action_id,
MaterialIconAction(
icon_name=icon_name, tooltip=tooltip, filled=True, parent=self
),
)
bundle.add_action(flat_action_id)
self.toolbar.add_bundle(bundle)
_build_flat_bundles("plots", PLOT_ACTIONS)
_build_flat_bundles("devices", DEVICE_ACTIONS)
_build_flat_bundles("utils", UTIL_ACTIONS)
# Workspace
spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components)
spacer = QWidget(parent=self.toolbar.components.toolbar)
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
spacer_bundle.add_action("spacer")
self.toolbar.add_bundle(spacer_bundle)
self.toolbar.add_bundle(workspace_bundle(self.toolbar.components))
self.toolbar.connect_bundle(
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
)
# Dock actions
self.toolbar.components.add_safe(
"attach_all",
MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
),
)
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
self.toolbar.components.add_safe(
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self)
)
# Developer mode toggle (moved from menu into toolbar)
self.toolbar.components.add_safe(
"developer_mode",
MaterialIconAction(
icon_name="code", tooltip="Developer Mode", checkable=True, parent=self
),
)
bda = ToolbarBundle("dock_actions", self.toolbar.components)
bda.add_action("attach_all")
bda.add_action("screenshot")
bda.add_action("dark_mode")
bda.add_action("developer_mode")
self.toolbar.add_bundle(bda)
# Default bundle configuration (show menus by default)
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
# Store mappings on self for use in _hook_toolbar
self._ACTION_MAPPINGS = {
"menu_plots": PLOT_ACTIONS,
"menu_devices": DEVICE_ACTIONS,
"menu_utils": UTIL_ACTIONS,
}
def _hook_toolbar(self):
def _connect_menu(menu_key: str):
menu = self.toolbar.components.get_action(menu_key)
mapping = self._ACTION_MAPPINGS[menu_key]
for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action
if widget_type == "LogPanel":
act.setEnabled(False) # keep disabled per issue #644
else:
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
_connect_menu("menu_plots")
_connect_menu("menu_devices")
_connect_menu("menu_utils")
# Connect flat toolbar actions
def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]):
for action_id, (_, _, widget_type) in mapping.items():
flat_action_id = f"flat_{action_id}"
flat_action = self.toolbar.components.get_action(flat_action_id).action
if widget_type == "LogPanel":
flat_action.setEnabled(False) # keep disabled per issue #644
else:
flat_action.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
_connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"])
_connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"])
_connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"])
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
# Developer mode toggle
self.toolbar.components.get_action("developer_mode").action.toggled.connect(
self._on_developer_mode_toggled
)
def _set_editable(self, editable: bool) -> None:
self.lock_workspace = not editable
self._editable = editable
# Sync the toolbar lock toggle with current mode
lock_action = self.toolbar.components.get_action("lock").action
lock_action.setChecked(not editable)
lock_action.setVisible(editable)
attach_all_action = self.toolbar.components.get_action("attach_all").action
attach_all_action.setVisible(editable)
# Show full creation menus only when editable; otherwise keep minimal set
if editable:
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
else:
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
# Keep Developer mode UI in sync
self.toolbar.components.get_action("developer_mode").action.setChecked(editable)
def _on_developer_mode_toggled(self, checked: bool) -> None:
"""Handle developer mode checkbox toggle."""
self._set_editable(checked)
################################################################################
# Adding widgets
################################################################################
@SafeSlot(popup_error=True)
def new(
self,
widget: BECWidget | str,
closable: bool = True,
floatable: bool = True,
movable: bool = True,
start_floating: bool = False,
where: Literal["left", "right", "top", "bottom"] | None = None,
) -> BECWidget:
"""
Create a new widget (or reuse an instance) and add it as a dock.
Args:
widget: Widget instance or a string widget type (factory-created).
closable: Whether the dock is closable.
floatable: Whether the dock is floatable.
movable: Whether the dock is movable.
start_floating: Start the dock in a floating state.
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
If None, uses the instance default passed at construction time.
Returns:
The widget instance.
"""
target_area = self._area_from_where(where)
# 1) Instantiate or look up the widget
if isinstance(widget, str):
widget = cast(BECWidget, widget_handler.create_widget(widget_type=widget, parent=self))
widget.name_established.connect(
lambda: self._create_dock_with_name(
widget=widget,
closable=closable,
floatable=floatable,
movable=movable,
start_floating=start_floating,
area=target_area,
)
)
return widget
# If a widget instance is passed, dock it immediately
self._create_dock_with_name(
widget=widget,
closable=closable,
floatable=floatable,
movable=movable,
start_floating=start_floating,
area=target_area,
)
return widget
def _create_dock_with_name(
self,
widget: BECWidget,
closable: bool = True,
floatable: bool = False,
movable: bool = True,
start_floating: bool = False,
area: QtAds.DockWidgetArea | None = None,
):
target_area = area or self._area_from_where(None)
self._make_dock(
widget,
closable=closable,
floatable=floatable,
movable=movable,
area=target_area,
start_floating=start_floating,
)
self.dock_manager.setFocus()
################################################################################
# Dock Management
################################################################################
def dock_map(self) -> dict[str, CDockWidget]:
"""
Return the dock widgets map as dictionary with names as keys and dock widgets as values.
Returns:
dict: A dictionary mapping widget names to their corresponding dock widgets.
"""
return self.dock_manager.dockWidgetsMap()
def dock_list(self) -> list[CDockWidget]:
"""
Return the list of dock widgets.
Returns:
list: A list of all dock widgets in the dock area.
"""
return self.dock_manager.dockWidgets()
def widget_map(self) -> dict[str, QWidget]:
"""
Return a dictionary mapping widget names to their corresponding BECWidget instances.
Returns:
dict: A dictionary mapping widget names to BECWidget instances.
"""
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
def widget_list(self) -> list[QWidget]:
"""
Return a list of all BECWidget instances in the dock area.
Returns:
list: A list of all BECWidget instances in the dock area.
"""
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
@SafeSlot()
def attach_all(self):
"""
Return all floating docks to the dock area, preserving tab groups within each floating container.
"""
for container in self.dock_manager.floatingWidgets():
docks = container.dockWidgets()
if not docks:
continue
target = docks[0]
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target)
for d in docks[1:]:
self.dock_manager.addDockWidgetTab(
QtAds.DockWidgetArea.RightDockWidgetArea, d, target
)
@SafeSlot()
def delete_all(self):
"""Delete all docks and widgets."""
for dock in list(self.dock_manager.dockWidgets()):
self._delete_dock(dock)
################################################################################
# Workspace Management
################################################################################
@SafeProperty(bool)
def lock_workspace(self) -> bool:
"""
Get or set the lock state of the workspace.
Returns:
bool: True if the workspace is locked, False otherwise.
"""
return self._locked
@lock_workspace.setter
def lock_workspace(self, value: bool):
"""
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
Args:
value (bool): True to lock the workspace, False to unlock it.
"""
self._locked = value
self._apply_dock_lock(value)
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
self.toolbar.components.get_action("delete_workspace").action.setVisible(not value)
for dock in self.dock_list():
dock.setting_action.setVisible(not value)
@SafeSlot(str)
def save_profile(self, name: str | None = None):
"""
Save the current workspace profile.
Args:
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
"""
if not name:
# Use the new SaveProfileDialog instead of QInputDialog
dialog = SaveProfileDialog(self)
if dialog.exec() != QDialog.Accepted:
return
name = dialog.get_profile_name()
readonly = dialog.is_readonly()
# Check if profile already exists and is read-only
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
suggested_name = f"{name}_custom"
reply = QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n"
f"Would you like to save it with a different name?\n"
f"Suggested name: '{suggested_name}'",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if reply == QMessageBox.Yes:
# Show dialog again with suggested name pre-filled
dialog = SaveProfileDialog(self, suggested_name)
if dialog.exec() != QDialog.Accepted:
return
name = dialog.get_profile_name()
readonly = dialog.is_readonly()
# Check again if the new name is also read-only (recursive protection)
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
return self.save_profile()
else:
return
else:
# If name is provided directly, assume not read-only unless already exists
readonly = False
if os.path.exists(profile_path(name)) and is_profile_readonly(name):
QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be overwritten.",
QMessageBox.Ok,
)
return
# Display saving placeholder
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
workspace_combo.blockSignals(True)
workspace_combo.insertItem(0, f"{name}-saving")
workspace_combo.setCurrentIndex(0)
workspace_combo.blockSignals(False)
# Save the profile
settings = open_settings(name)
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
settings.setValue(
SETTINGS_KEYS["state"], b""
) # No QMainWindow state; placeholder for backward compat
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
self.dock_manager.addPerspective(name)
self.dock_manager.savePerspectives(settings)
self.state_manager.save_state(settings=settings)
write_manifest(settings, self.dock_list())
# Set read-only status if specified
if readonly:
set_profile_readonly(name, readonly)
settings.sync()
self._refresh_workspace_list()
workspace_combo.setCurrentText(name)
def load_profile(self, name: str | None = None):
"""
Load a workspace profile.
Args:
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
"""
# FIXME this has to be tweaked
if not name:
name, ok = QInputDialog.getText(
self, "Load Workspace", "Enter the name of the workspace profile to load:"
)
if not ok or not name:
return
settings = open_settings(name)
for item in read_manifest(settings):
obj_name = item["object_name"]
widget_class = item["widget_class"]
if obj_name not in self.widget_map():
w = widget_handler.create_widget(widget_type=widget_class, parent=self)
w.setObjectName(obj_name)
self._make_dock(
w,
closable=item["closable"],
floatable=item["floatable"],
movable=item["movable"],
area=QtAds.DockWidgetArea.RightDockWidgetArea,
)
geom = settings.value(SETTINGS_KEYS["geom"])
if geom:
self.restoreGeometry(geom)
# No window state for QWidget-based host; keep for backwards compat read
# window_state = settings.value(SETTINGS_KEYS["state"]) # ignored
dock_state = settings.value(SETTINGS_KEYS["ads_state"])
if dock_state:
self.dock_manager.restoreState(dock_state)
self.dock_manager.loadPerspectives(settings)
self.state_manager.load_state(settings=settings)
self._set_editable(self._editable)
@SafeSlot()
def delete_profile(self):
"""
Delete the currently selected workspace profile file and refresh the combo list.
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
name = combo.currentText()
if not name:
return
# Check if profile is read-only
if is_profile_readonly(name):
QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n"
f"Read-only profiles are protected from modification and deletion.",
QMessageBox.Ok,
)
return
# Confirm deletion for regular profiles
reply = QMessageBox.question(
self,
"Delete Profile",
f"Are you sure you want to delete the profile '{name}'?\n\n"
f"This action cannot be undone.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
file_path = profile_path(name)
try:
os.remove(file_path)
except FileNotFoundError:
return
self._refresh_workspace_list()
def _refresh_workspace_list(self):
"""
Populate the workspace combo box with all saved profile names (without .ini).
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
if hasattr(combo, "refresh_profiles"):
combo.refresh_profiles()
else:
# Fallback for regular QComboBox
combo.blockSignals(True)
combo.clear()
combo.addItems(list_profiles())
combo.blockSignals(False)
################################################################################
# Mode Switching
################################################################################
@SafeProperty(str)
def mode(self) -> str:
return self._mode
@mode.setter
def mode(self, new_mode: str):
if new_mode not in ["plot", "device", "utils", "developer", "user"]:
raise ValueError(f"Invalid mode: {new_mode}")
self._mode = new_mode
self.mode_changed.emit(new_mode)
# Update toolbar visibility based on mode
if new_mode == "user":
# User mode: show only essential tools
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
elif new_mode == "developer":
# Developer mode: show all tools (use menu bundles)
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
elif new_mode in ["plot", "device", "utils"]:
# Specific modes: show flat toolbar for that category
bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils"
self.toolbar.show_bundles([bundle_name])
# self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"])
else:
# Fallback to user mode
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
def cleanup(self):
"""
Cleanup the dock area.
"""
self.delete_all()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
self.toolbar.cleanup()
super().cleanup()
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
dispatcher = BECDispatcher(gui_id="ads")
window = BECMainWindowNoRPC()
ads = AdvancedDockArea(mode="developer", root_widget=True)
window.setCentralWidget(ads)
window.show()
window.resize(800, 600)
sys.exit(app.exec())

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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)

View File

@@ -71,6 +71,7 @@ class BECDockArea(BECWidget, QWidget):
"detach_dock",
"attach_all",
"save_state",
"screenshot",
"restore_state",
]
@@ -267,11 +268,16 @@ class BECDockArea(BECWidget, QWidget):
"restore_state",
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
)
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
bundle.add_action("attach_all")
bundle.add_action("save_state")
bundle.add_action("restore_state")
bundle.add_action("screenshot")
self.toolbar.add_bundle(bundle)
def _hook_toolbar(self):
@@ -333,6 +339,7 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.components.get_action("restore_state").action.triggered.connect(
self.restore_state
)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:
@@ -609,10 +616,10 @@ if __name__ == "__main__": # pragma: no cover
import sys
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
set_theme("auto")
apply_theme("dark")
dock_area = BECDockArea()
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
dock_1.new(widget="DarkModeButton")

View 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()

View 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()

View 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()

View File

@@ -19,7 +19,7 @@ from qtpy.QtWidgets import (
import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme, set_theme
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
@@ -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)
@@ -374,11 +374,12 @@ class BECMainWindow(BECWidget, QMainWindow):
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
# Set the default theme
theme = self.app.theme.theme
if theme == "light":
light_theme_action.setChecked(True)
elif theme == "dark":
dark_theme_action.setChecked(True)
if hasattr(self.app, "theme") and self.app.theme:
theme_name = self.app.theme.theme.lower()
if "light" in theme_name:
light_theme_action.setChecked(True)
elif "dark" in theme_name:
dark_theme_action.setChecked(True)
########################################
# Help menu
@@ -448,7 +449,7 @@ class BECMainWindow(BECWidget, QMainWindow):
Args:
theme(str): Either "light" or "dark".
"""
set_theme(theme) # emits theme_updated and applies palette globally
apply_theme(theme) # emits theme_updated and applies palette globally
def event(self, event):
if event.type() == QEvent.Type.StatusTip:

View File

@@ -38,9 +38,6 @@ class AbortButton(BECWidget, QWidget):
else:
self.button = QPushButton()
self.button.setText("Abort")
self.button.setStyleSheet(
"background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
)
self.button.clicked.connect(self.abort_scan)
self.layout.addWidget(self.button)

View File

@@ -31,9 +31,7 @@ class StopButton(BECWidget, QWidget):
self.button = QPushButton()
self.button.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
self.button.setText("Stop")
self.button.setStyleSheet(
f"background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;"
)
self.button.setProperty("variant", "danger")
self.button.clicked.connect(self.stop_scan)
self.layout.addWidget(self.button)

View File

@@ -12,7 +12,7 @@ from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner"]
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)
@@ -259,7 +259,7 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")
apply_theme("dark")
widget = PositionerBox(device="bpm4i")
widget.show()

View File

@@ -13,7 +13,7 @@ from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
@@ -34,7 +34,7 @@ class PositionerBox2D(PositionerBoxBase):
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "attach", "detach", "screenshot"]
device_changed_hor = Signal(str, str)
device_changed_ver = Signal(str, str)
@@ -478,7 +478,7 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")
apply_theme("dark")
widget = PositionerBox2D()
widget.show()

View File

@@ -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)

View File

@@ -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]):

View File

@@ -147,24 +147,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
dev_name = self.currentText()
return self.get_device_object(dev_name)
def paintEvent(self, event: QPaintEvent) -> None:
"""Extend the paint event to set the border color based on the validity of the input.
Args:
event (PySide6.QtGui.QPaintEvent) : Paint event.
"""
# logger.info(f"Received paint event: {event} in {self.__class__}")
super().paintEvent(event)
if self._is_valid_input is False and self.isEnabled() is True:
painter = QPainter(self)
pen = QPen()
pen.setWidth(2)
pen.setColor(self._accent_colors.emergency)
painter.setPen(pen)
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
painter.end()
@Slot(str)
def check_validity(self, input_text: str) -> None:
"""
@@ -173,10 +155,12 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
if self.validate_device(input_text) is True:
self._is_valid_input = True
self.device_selected.emit(input_text)
self.setStyleSheet("border: 1px solid transparent;")
else:
self._is_valid_input = False
self.device_reset.emit()
self.update()
if self.isEnabled():
self.setStyleSheet("border: 1px solid red;")
def validate_device(self, device: str) -> bool: # type: ignore[override]
"""
@@ -202,10 +186,10 @@ if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
set_theme("dark")
apply_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()

View File

@@ -175,13 +175,13 @@ if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import (
SignalComboBox,
)
app = QApplication([])
set_theme("dark")
apply_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()

View File

@@ -179,10 +179,10 @@ if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
set_theme("dark")
apply_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()

View File

@@ -147,13 +147,13 @@ if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import (
DeviceComboBox,
)
app = QApplication([])
set_theme("dark")
apply_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()

View File

@@ -0,0 +1,4 @@
from .device_table_view import DeviceTableView
from .dm_config_view import DMConfigView
from .dm_docstring_view import DocstringView
from .dm_ophyd_test import DMOphydTest

View File

@@ -0,0 +1,3 @@
from .available_device_resources import AvailableDeviceResources
__all__ = ["AvailableDeviceResources"]

View File

@@ -0,0 +1,87 @@
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]):
item = QListWidgetItem(self.tag_groups_list)
tag_group_widget = DeviceTagGroup(self.tag_groups_list, tag_group, devices)
self.tag_groups_list.setItemWidget(item, tag_group_widget)
self.tag_groups_list.addItem(item)
self._items[tag_group] = (item, tag_group_widget)
item.setSizeHint(QSize(tag_group_widget.width(), tag_group_widget.height()))
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())

View File

@@ -0,0 +1,27 @@
from qtpy.QtCore import QMetaObject, Qt
from qtpy.QtWidgets import QAbstractItemView, QListView, QListWidget, QVBoxLayout
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 = QListWidget(availableDeviceResources)
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)

View File

@@ -0,0 +1,160 @@
from __future__ import annotations
import operator
from functools import reduce
from glob import glob
from pathlib import Path
from textwrap import dedent
from typing import AbstractSet, Protocol
from bec_lib.atlas_models import Device
from bec_lib.bec_yaml_loader import yaml_load
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from pydantic import model_validator
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:
config_values = sorted(
(str(kv) for kv in self.deviceConfig.items()) if self.deviceConfig else []
)
return (reduce(operator.add, (self.name, self.deviceClass, *config_values))).__hash__()
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_file() or 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_file(self):
return None
# return _devices_from_file(
# "/home/perl_d/Development/bec/bec/logs/device_configs/recovery_configs/recovery_config_2025-08-22_14-02-29.yaml"
# )
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()

View File

@@ -0,0 +1,189 @@
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.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_item_ui import (
Ui_DeviceTagGroup,
)
DEVICE_HASH_ROLE = 101
def _warning_string(spec: HashableDevice):
names_str = "\n ".join(spec.names)
msg = (
f"Device defined with multiple names! Please check:\n {names_str}\n"
if len(spec.names) > 1
else ""
)
source_str = "\n ".join(spec.source_files)
source_warning = (
f"Device found in multiple source files! Please check:\n {source_str}"
if len(spec.source_files) > 1
else ""
)
return f"{msg}{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(QWidget, Ui_DeviceTagGroup):
def __init__(
self, parent=None, name: str = "TagGroupTitle", data: set[HashableDevice] = set(), **kwargs
):
super().__init__(parent=parent, **kwargs)
self.setupUi(self)
self.device_list.setGridSize(_DeviceEntryWidget._grid_size)
self.title.setText(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())

View File

@@ -0,0 +1,135 @@
import math
from functools import partial
from bec_qthemes import material_icon
from qtpy.QtCore import QMetaObject, QSize, Qt
from qtpy.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QLabel,
QListView,
QListWidget,
QSizePolicy,
QSpacerItem,
QToolButton,
QVBoxLayout,
)
class AutoHeightListWidget(QListWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setViewMode(QListView.ViewMode.IconMode)
self.setResizeMode(QListView.ResizeMode.Adjust)
self.setWrapping(True)
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(DeviceTagGroup)
self.verticalLayout.setObjectName("verticalLayout")
self.frame = QFrame(DeviceTagGroup)
self.frame.setObjectName("frame")
self.frame.setFrameShape(QFrame.Shape.StyledPanel)
self.frame.setFrameShadow(QFrame.Shadow.Raised)
self.verticalLayout_2 = QVBoxLayout(self.frame)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.title = QLabel(self.frame)
self.title.setObjectName("title")
self.horizontalLayout.addWidget(self.title)
self.n_included = QLabel(self.frame, text="...")
self.n_included.setObjectName("n_included")
self.horizontalLayout.addWidget(self.n_included)
self.horizontalSpacer = QSpacerItem(
40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum
)
self.horizontalLayout.addItem(self.horizontalSpacer)
self.delete_tag_button = QToolButton(self.frame)
self.delete_tag_button.setObjectName("delete_tag_button")
self.horizontalLayout.addWidget(self.delete_tag_button)
self.remove_from_composition_button = QToolButton(self.frame)
self.remove_from_composition_button.setObjectName("remove_from_composition_button")
self.horizontalLayout.addWidget(self.remove_from_composition_button)
self.add_to_composition_button = QToolButton(self.frame)
self.add_to_composition_button.setObjectName("add_to_composition_button")
self.horizontalLayout.addWidget(self.add_to_composition_button)
self.remove_all_button = QToolButton(self.frame)
self.remove_all_button.setObjectName("remove_all_from_composition_button")
self.horizontalLayout.addWidget(self.remove_all_button)
self.add_all_button = QToolButton(self.frame)
self.add_all_button.setObjectName("add_all_to_composition_button")
self.horizontalLayout.addWidget(self.add_all_button)
self.verticalLayout_2.addLayout(self.horizontalLayout)
self.device_list = AutoHeightListWidget(self.frame)
self.device_list.setObjectName("device_list")
self.verticalLayout_2.addWidget(self.device_list)
self.verticalLayout.addWidget(self.frame)
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")

View File

@@ -0,0 +1,810 @@
"""Module with the device table view implementation."""
from __future__ import annotations
import copy
import time
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_signal_proxy import BECSignalProxy
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus
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)
description = row_dict.get("description", "")
QtWidgets.QToolTip.showText(event.globalPos(), description, view)
return True
class CenterCheckBoxDelegate(DictToolTipDelegate):
"""Custom checkbox delegate to center checkboxes in table cells."""
def __init__(self, parent=None, colors=None):
super().__init__(parent)
self._colors = colors if colors else get_accent_colors()
self._icon_checked = material_icon(
"check_box", size=QtCore.QSize(16, 16), color=self._colors.default, filled=True
)
self._icon_unchecked = material_icon(
"check_box_outline_blank",
size=QtCore.QSize(16, 16),
color=self._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 DeviceValidatedDelegate(DictToolTipDelegate):
"""Custom delegate for displaying validated device configurations."""
def __init__(self, parent=None, colors=None):
super().__init__(parent)
self._colors = colors if colors else get_accent_colors()
self._icons = {
ValidationStatus.PENDING: material_icon(
icon_name="circle", size=(12, 12), color=self._colors.default, filled=True
),
ValidationStatus.VALID: material_icon(
icon_name="circle", size=(12, 12), color=self._colors.success, filled=True
),
ValidationStatus.FAILED: material_icon(
icon_name="circle", size=(12, 12), color=self._colors.emergency, filled=True
),
}
def apply_theme(self, theme: str | None = None):
colors = get_accent_colors()
for status, icon in self._icons.items():
icon.setColor(colors[status])
def paint(self, painter, option, index):
status = index.model().data(index, QtCore.Qt.DisplayRole)
if status is None:
return super().paint(painter, option, index)
pixmap = self._icons.get(status)
if pixmap:
rect = option.rect
pix_rect = pixmap.rect()
pix_rect.moveCenter(rect.center())
painter.drawPixmap(pix_rect.topLeft(), pixmap)
super().paint(painter, option, index)
class WrappingTextDelegate(DictToolTipDelegate):
"""Custom delegate for wrapping text in table cells."""
def __init__(self, table: BECTableView, parent=None):
super().__init__(parent)
self._table = table
def paint(self, painter, option, index):
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 "")
column_width = self._table.columnWidth(index.column()) - 8 # -4 & 4
# Avoid pathological heights for too-narrow columns
min_width = option.fontMetrics.averageCharWidth() * 4
if column_width < min_width:
fm = QtGui.QFontMetrics(option.font)
elided = fm.elidedText(text, QtCore.Qt.ElideRight, column_width)
return QtCore.QSize(column_width, fm.height() + 4)
doc = QtGui.QTextDocument()
doc.setDefaultFont(option.font)
doc.setTextWidth(column_width)
doc.setPlainText(text)
layout_height = doc.documentLayout().documentSize().height()
return QtCore.QSize(column_width, int(layout_height) + 4)
# 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_configs_added = QtCore.Signal(dict) # Dict[str, dict] of configs that were added
devices_removed = QtCore.Signal(list) # List of strings with device names that were removed
def __init__(self, parent=None):
super().__init__(parent)
self._device_config: dict[str, dict] = {}
self._list_items: list[dict] = []
self._validation_status: dict[str, ValidationStatus] = {}
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._list_items)
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._list_items[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()
if col == 0 and role == QtCore.Qt.DisplayRole: # QtCore.Qt.DisplayRole:
dev_name = self._list_items[row].get("name", "")
return self._validation_status.get(dev_name, ValidationStatus.PENDING)
key = self.headers[col]
value = self._list_items[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._list_items[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) -> dict[str, dict]:
"""Method to get the device configuration."""
return self._device_config
def add_device_configs(self, device_configs: dict[str, dict]):
"""
Add devices to the model.
Args:
device_configs (dict[str, dict]): A dictionary of device configurations to add.
"""
already_in_list = []
for k, cfg in device_configs.items():
if k in self._device_config:
logger.warning(f"Device {k} already exists in the model.")
already_in_list.append(k)
continue
self._device_config[k] = cfg
new_list_cfg = copy.deepcopy(cfg)
new_list_cfg["name"] = k
row = len(self._list_items)
self.beginInsertRows(QtCore.QModelIndex(), row, row)
self._list_items.append(new_list_cfg)
self.endInsertRows()
for k in already_in_list:
device_configs.pop(k)
self.device_configs_added.emit(device_configs)
def set_device_config(self, device_configs: dict[str, dict]):
"""
Replace the device config.
Args:
device_config (dict[str, dict]): The new device config to set.
"""
diff_names = set(device_configs.keys()) - set(self._device_config.keys())
self.beginResetModel()
self._device_config.clear()
self._list_items.clear()
for k, cfg in device_configs.items():
self._device_config[k] = cfg
new_list_cfg = copy.deepcopy(cfg)
new_list_cfg["name"] = k
self._list_items.append(new_list_cfg)
self.endResetModel()
self.devices_removed.emit(diff_names)
self.device_configs_added.emit(device_configs)
def remove_device_configs(self, device_configs: dict[str, dict]):
"""
Remove devices from the model.
Args:
device_configs (dict[str, dict]): A dictionary of device configurations to remove.
"""
removed = []
for k in device_configs.keys():
if k not in self._device_config:
logger.warning(f"Device {k} does not exist in the model.")
continue
new_cfg = self._device_config.pop(k)
new_cfg["name"] = k
row = self._list_items.index(new_cfg)
self.beginRemoveRows(QtCore.QModelIndex(), row, row)
self._list_items.pop(row)
self.endRemoveRows()
removed.append(k)
self.devices_removed.emit(removed)
def clear_table(self):
"""
Clear the table.
"""
device_names = list(self._device_config.keys())
self.beginResetModel()
self._device_config.clear()
self._list_items.clear()
self.endResetModel()
self.devices_removed.emit(device_names)
def update_validation_status(self, device_name: str, status: int | ValidationStatus):
"""
Handle device status changes.
Args:
device_name (str): The name of the device.
status (int): The new status of the device.
"""
if isinstance(status, int):
status = ValidationStatus(status)
if device_name not in self._device_config:
logger.warning(
f"Device {device_name} not found in device_config dict {self._device_config}"
)
return
self._validation_status[device_name] = status
row = None
for ii, item in enumerate(self._list_items):
if item["name"] == device_name:
row = ii
break
if row is None:
logger.warning(
f"Device {device_name} not found in device_status dict {self._validation_status}"
)
return
# Emit dataChanged for column 0 (status column)
index = self.index(row, 0)
self.dataChanged.emit(index, index, [QtCore.Qt.DisplayRole])
class BECTableView(QtWidgets.QTableView):
"""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
source_rows = self._get_source_rows(proxy_indexes)
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 _get_source_rows(self, proxy_indexes: list[QtWidgets.QModelIndex]) -> list[int]:
"""
Map proxy model indices to source model row indices.
Args:
proxy_indexes (list[QModelIndex]): List of proxy model indices.
Returns:
list[int]: List of source model row indices.
"""
proxy_rows = sorted({idx for idx in proxy_indexes}, reverse=True)
source_rows = [self.model().mapToSource(idx).row() for idx in proxy_rows]
return list(set(source_rows))
def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool:
"""
Prompt the user to confirm removal of rows and remove them from the model if accepted.
Returns True if rows were removed, False otherwise.
"""
configs = [model._list_items[r] for r in sorted(source_rows)]
names = [cfg.get("name", "<unknown>") for cfg in configs]
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:
configs_to_be_removed = {model._device_config[name] for name in names}
model.remove_device_configs(configs_to_be_removed)
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 = [1, 2] # 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) # Selected device configuration dict[str,dict]
device_configs_added = QtCore.Signal(dict) # Dict[str, dict] of configs that were added
devices_removed = QtCore.Signal(list) # List of strings with device names that were removed
RPC = False
PLUGIN = False
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)
# Connect signals
self._model.devices_removed.connect(self.devices_removed.emit)
self._model.device_configs_added.connect(self.device_configs_added.emit)
def _setup_search(self):
"""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
colors = get_accent_colors()
self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors)
self.wrap_delegate = WrappingTextDelegate(self.table)
self.tool_tip_delegate = DictToolTipDelegate(self.table)
self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors)
self.table.setItemDelegateForColumn(0, self.validated_delegate) # ValidationStatus
self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name
self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass
self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority
self.table.setItemDelegateForColumn(4, self.wrap_delegate) # deviceTags
self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # enabled
self.table.setItemDelegateForColumn(6, self.checkbox_delegate) # readOnly
# Column resize policies
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed) # ValidationStatus
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # name
header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # deviceClass
header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority
header.setSectionResizeMode(4, QtWidgets.QHeaderView.Stretch) # deviceTags
header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) # enabled
header.setSectionResizeMode(6, QtWidgets.QHeaderView.Fixed) # readOnly
self.table.setColumnWidth(0, 25)
self.table.setColumnWidth(5, 70)
self.table.setColumnWidth(6, 70)
# Ensure column widths stay fixed
header.setMinimumSectionSize(25)
header.setDefaultSectionSize(90)
# Enable resizing of column
self._geometry_resize_proxy = BECSignalProxy(
header.geometriesChanged, rateLimit=10, slot=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 get_device_config(self) -> dict[str, 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)
self.validated_delegate.apply_theme(theme)
######################################
########### Slot API #################
######################################
@SafeSlot()
def _on_table_resized(self, *args):
"""Handle changes to the table column resizing."""
option = QtWidgets.QStyleOptionViewItem()
model = self.table.model()
for row in range(model.rowCount()):
index = model.index(row, 4)
height = self.wrap_delegate.sizeHint(option, index).height()
self.table.setRowHeight(row, height)
@SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection)
def _on_selection_changed(
self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection
) -> None:
"""
Handle selection changes in the device table.
Args:
selected (QtCore.QItemSelection): The selected items.
deselected (QtCore.QItemSelection): The deselected items.
"""
# TODO also hook up logic if a config update is propagated from somewhere!
# selected_indexes = selected.indexes()
selected_indexes = self.table.selectionModel().selectedIndexes()
if not selected_indexes:
return
source_indexes = [self.proxy.mapToSource(idx) for idx in selected_indexes]
source_rows = {idx.row() for idx in source_indexes}
configs = [copy.deepcopy(self._model._list_items[r]) for r in sorted(source_rows)]
names = [cfg.pop("name") for cfg in configs]
selected_cfgs = {name: cfg for name, cfg in zip(names, configs)}
self.selected_device.emit(selected_cfgs)
######################################
##### Ext. Slot API #################
######################################
@SafeSlot(dict)
def set_device_config(self, device_configs: dict[str, dict]):
"""
Set the device config.
Args:
config (dict[str,dict]): The device config to set.
"""
self._model.set_device_config(device_configs)
@SafeSlot()
def clear_device_configs(self):
"""Clear the device configs."""
self._model.clear_table()
@SafeSlot(dict)
def add_device_configs(self, device_configs: dict[str, dict]):
"""
Add devices to the config.
Args:
device_configs (dict[str, dict]): The device configs to add.
"""
self._model.add_device_configs(device_configs)
@SafeSlot(dict)
def remove_device_configs(self, device_configs: dict[str, dict]):
"""
Remove devices from the config.
Args:
device_configs (dict[str, dict]): The device configs to remove.
"""
self._model.remove_device_configs(device_configs)
@SafeSlot(str)
def remove_device(self, device_name: str):
"""
Remove a device from the config.
Args:
device_name (str): The name of the device to remove.
"""
cfg = self._model._device_config.get(device_name, None)
if cfg is None:
logger.warning(f"Device {device_name} not found in device_config dict")
return
self._model.remove_device_configs({device_name: cfg})
@SafeSlot(str, int)
def update_device_validation(
self, device_name: str, validation_status: int | ValidationStatus
) -> None:
"""
Update the validation status of a device.
Args:
device_name (str): The name of the device.
validation_status (int | ValidationStatus): The new validation status.
"""
self._model.update_validation_status(device_name, validation_status)
if __name__ == "__main__":
import sys
import numpy as np
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
window = DeviceTableView()
layout.addWidget(window)
# QPushButton
button = QtWidgets.QPushButton("Test status_update")
layout.addWidget(button)
def _button_clicked():
names = list(window._model._device_config.keys())
for name in names:
window.update_device_validation(
name, ValidationStatus.VALID if np.random.rand() > 0.5 else ValidationStatus.FAILED
)
button.clicked.connect(_button_clicked)
# pylint: disable=protected-access
config = window.client.device_manager._get_redis_device_config()
names = [cfg.pop("name") for cfg in config]
config_dict = {name: cfg for name, cfg in zip(names, config)}
window.set_device_config(config_dict)
widget.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,83 @@
"""Module with a config view for the device manager."""
from __future__ import annotations
import traceback
import yaml
from bec_lib.logger import bec_logger
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
logger = bec_logger.logger
class DMConfigView(BECWidget, QtWidgets.QWidget):
def __init__(self, parent=None, client=None):
super().__init__(client=client, parent=parent, theme_update=True)
self.stacked_layout = QtWidgets.QStackedLayout()
self.stacked_layout.setContentsMargins(0, 0, 0, 0)
self.stacked_layout.setSpacing(0)
self.setLayout(self.stacked_layout)
# Monaco widget
self.monaco_editor = MonacoWidget()
self._customize_monaco()
self.stacked_layout.addWidget(self.monaco_editor)
self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config")
self._customize_overlay()
self.stacked_layout.addWidget(self._overlay_widget)
self.stacked_layout.setCurrentWidget(self._overlay_widget)
def _customize_monaco(self):
self.monaco_editor.set_language("yaml")
self.monaco_editor.set_vim_mode_enabled(False)
self.monaco_editor.set_minimap_enabled(False)
# self.monaco_editor.setFixedHeight(600)
self.monaco_editor.set_readonly(True)
self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False)
self.monaco_editor.editor.set_line_numbers_mode("off")
def _customize_overlay(self):
self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._overlay_widget.setAutoFillBackground(True)
self._overlay_widget.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding
)
@SafeSlot(dict)
def on_select_config(self, device: dict):
"""Handle selection of a device from the device table."""
if len(device) != 1:
text = ""
self.stacked_layout.setCurrentWidget(self._overlay_widget)
else:
try:
text = yaml.dump(device, default_flow_style=False)
self.stacked_layout.setCurrentWidget(self.monaco_editor)
except Exception:
content = traceback.format_exc()
logger.error(f"Error converting device to YAML:\n{content}")
text = ""
self.stacked_layout.setCurrentWidget(self._overlay_widget)
self.monaco_editor.set_readonly(False) # Enable editing
text = text.rstrip()
self.monaco_editor.set_text(text)
self.monaco_editor.set_readonly(True) # Disable editing again
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
config_view = DMConfigView()
config_view.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,128 @@
"""Module to visualize the docstring of a device class."""
from __future__ import annotations
import inspect
import re
import traceback
from bec_lib.logger import bec_logger
from bec_lib.plugin_helper import get_plugin_class, plugin_package_name
from bec_lib.utils.rpc_utils import rgetattr
from qtpy import QtCore, QtWidgets
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
try:
import ophyd
import ophyd_devices
READY_TO_VIEW = True
except ImportError:
logger.warning(f"Optional dependencies not available: {ImportError}")
ophyd_devices = None
ophyd = None
class DocstringView(QtWidgets.QTextEdit):
def __init__(self, parent: QtWidgets.QWidget | None = None):
super().__init__(parent)
self.setReadOnly(True)
self.setFocusPolicy(QtCore.Qt.NoFocus)
if not READY_TO_VIEW:
self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.")
self.setEnabled(False)
return
def _format_docstring(self, doc: str | None) -> str:
if not doc:
return "<i>No docstring available.</i>"
# Escape HTML
doc = doc.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# Remove leading/trailing blank lines from the entire docstring
lines = [line.rstrip() for line in doc.splitlines()]
while lines and lines[0].strip() == "":
lines.pop(0)
while lines and lines[-1].strip() == "":
lines.pop()
doc = "\n".join(lines)
# Improved regex: match section header + all following indented lines
section_regex = re.compile(
r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b(?:\n([ \t]+.*))*",
re.MULTILINE,
)
def strip_section(match: re.Match) -> str:
# Capture all lines in the match
block = match.group(0)
lines = block.splitlines()
# Remove leading/trailing empty lines within the section
lines = [line for line in lines if line.strip() != ""]
return "\n".join(lines)
doc = section_regex.sub(strip_section, doc)
# Highlight section titles
doc = re.sub(
r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b", r"<b>\1</b>", doc
)
# Convert indented blocks to <pre> and strip leading/trailing newlines
def pre_block(match: re.Match) -> str:
text = match.group(0).strip("\n")
return f"<pre>{text}</pre>"
doc = re.sub(r"(?m)(?:\n[ \t]+.*)+", pre_block, doc)
# Replace remaining newlines with <br> and collapse multiple <br>
doc = doc.replace("\n", "<br>")
doc = re.sub(r"(<br>)+", r"<br>", doc)
doc = doc.strip("<br>")
return f"<div style='font-family: sans-serif; font-size: 12pt;'>{doc}</div>"
def _set_text(self, text: str):
self.setReadOnly(False)
self.setMarkdown(text)
# self.setHtml(self._format_docstring(text))
self.setReadOnly(True)
@SafeSlot(dict)
def on_select_config(self, device: dict):
if len(device) != 1:
self._set_text("")
return
k = next(iter(device))
device_class = device[k].get("deviceClass", "")
self.set_device_class(device_class)
@SafeSlot(str)
def set_device_class(self, device_class_str: str) -> None:
docstring = ""
if not READY_TO_VIEW:
return
try:
module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd])
docstring = inspect.getdoc(module_cls)
self._set_text(docstring or "No docstring available.")
except Exception:
content = traceback.format_exc()
logger.error(f"Error retrieving docstring for {device_class_str}: {content}")
self._set_text(f"Error retrieving docstring for {device_class_str}")
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
config_view = DocstringView()
config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera")
config_view.show()
sys.exit(app.exec_())

View File

@@ -0,0 +1,414 @@
"""Module to run a static tests for devices from a yaml config."""
from __future__ import annotations
import enum
import re
import traceback
from html import escape
from typing import TYPE_CHECKING
import bec_lib
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from ophyd import status
from qtpy import QtCore, QtGui, QtWidgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget
READY_TO_TEST = False
logger = bec_logger.logger
try:
import bec_server
import ophyd_devices
READY_TO_TEST = True
except ImportError:
logger.warning(f"Optional dependencies not available: {ImportError}")
ophyd_devices = None
bec_server = None
if TYPE_CHECKING: # pragma no cover
try:
from ophyd_devices.utils.static_device_test import StaticDeviceTest
except ImportError:
StaticDeviceTest = None
class ValidationStatus(int, enum.Enum):
"""Validation status for device configurations."""
PENDING = 0 # colors.default
VALID = 1 # colors.highlight
FAILED = 2 # colors.emergency
class DeviceValidationResult(QtCore.QObject):
"""Simple object to inject validation signals into QRunnable."""
# Device validation signal, device_name, ValidationStatus as int, error message or ''
device_validated = QtCore.Signal(str, bool, str)
class DeviceValidationRunnable(QtCore.QRunnable):
"""Runnable for validating a device configuration."""
def __init__(
self,
device_name: str,
config: dict,
static_device_test: StaticDeviceTest | None,
connect: bool = False,
):
"""
Initialize the device validation runnable.
Args:
device_name (str): The name of the device to validate.
config (dict): The configuration dictionary for the device.
static_device_test (StaticDeviceTest): The static device test instance.
connect (bool, optional): Whether to connect to the device. Defaults to False.
"""
super().__init__()
self.device_name = device_name
self.config = config
self._connect = connect
self._static_device_test = static_device_test
self.signals = DeviceValidationResult()
def run(self):
"""Run method for device validation."""
if self._static_device_test is None:
logger.error(
f"Ophyd devices or bec_server not available, cannot run validation for device {self.device_name}."
)
return
try:
self._static_device_test.config = {self.device_name: self.config}
results = self._static_device_test.run_with_list_output(connect=self._connect)
success = results[0].success
msg = results[0].message
self.signals.device_validated.emit(self.device_name, success, msg)
except Exception:
content = traceback.format_exc()
logger.error(f"Validation failed for device {self.device_name}. Exception: {content}")
self.signals.device_validated.emit(self.device_name, False, content)
class ValidationListItem(QtWidgets.QWidget):
"""Custom list item widget showing device name and validation status."""
def __init__(self, device_name: str, device_config: dict, parent=None):
"""
Initialize the validation list item.
Args:
device_name (str): The name of the device.
device_config (dict): The configuration of the device.
validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status.
parent (QtWidgets.QWidget, optional): The parent widget.
"""
super().__init__(parent)
self.main_layout = QtWidgets.QHBoxLayout(self)
self.main_layout.setContentsMargins(2, 2, 2, 2)
self.main_layout.setSpacing(4)
self.device_name = device_name
self.device_config = device_config
self.validation_msg = "Validation in progress..."
self._setup_ui()
def _setup_ui(self):
"""Setup the UI for the list item."""
label = QtWidgets.QLabel(self.device_name)
self.main_layout.addWidget(label)
self.main_layout.addStretch()
self._spinner = SpinnerWidget(parent=self)
self._spinner.speed = 80
self._spinner.setFixedSize(24, 24)
self.main_layout.addWidget(self._spinner)
self._base_style = "font-weight: bold;"
self.setStyleSheet(self._base_style)
self._start_spinner()
def _start_spinner(self):
"""Start the spinner animation."""
self._spinner.start()
QtWidgets.QApplication.processEvents()
def _stop_spinner(self):
"""Stop the spinner animation."""
self._spinner.stop()
self._spinner.setVisible(False)
@SafeSlot()
def on_validation_restart(self):
"""Handle validation restart."""
self.validation_msg = ""
self._start_spinner()
self.setStyleSheet("") # Check if this works as expected
@SafeSlot(str)
def on_validation_failed(self, error_msg: str):
"""Handle validation failure."""
self.validation_msg = error_msg
colors = get_accent_colors()
self._stop_spinner()
self.main_layout.removeWidget(self._spinner)
self._spinner.deleteLater()
label = QtWidgets.QLabel("")
icon = material_icon("error", color=colors.emergency, size=(24, 24))
label.setPixmap(icon)
self.main_layout.addWidget(label)
class DMOphydTest(BECWidget, QtWidgets.QWidget):
"""Widget to test device configurations using ophyd devices."""
# Signal to emit the validation status of a device
device_validated = QtCore.Signal(str, int)
def __init__(self, parent=None, client=None):
super().__init__(parent=parent, client=client)
if not READY_TO_TEST:
self.setDisabled(True)
self.static_device_test = None
else:
from ophyd_devices.utils.static_device_test import StaticDeviceTest
self.static_device_test = StaticDeviceTest(config_dict={})
self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {}
self._thread_pool = QtCore.QThreadPool.globalInstance()
self._main_layout = QtWidgets.QVBoxLayout(self)
self._main_layout.setContentsMargins(0, 0, 0, 0)
self._main_layout.setSpacing(4)
# We add a splitter between the list and the text box
self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical)
self._main_layout.addWidget(self.splitter)
self._setup_list_ui()
self._setup_textbox_ui()
def _setup_list_ui(self):
"""Setup the list UI."""
self._list_widget = QtWidgets.QListWidget(self)
self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self.splitter.addWidget(self._list_widget)
# Connect signals
self._list_widget.currentItemChanged.connect(self._on_current_item_changed)
def _setup_textbox_ui(self):
"""Setup the text box UI."""
self._text_box = QtWidgets.QTextEdit(self)
self._text_box.setReadOnly(True)
self._text_box.setFocusPolicy(QtCore.Qt.NoFocus)
self.splitter.addWidget(self._text_box)
@SafeSlot(dict)
def add_device_configs(self, device_configs: dict[str, dict]) -> None:
"""Receive an update with device configs.
Args:
device_configs (dict[str, dict]): The updated device configurations.
"""
for device_name, device_config in device_configs.items():
if device_name in self._device_list_items:
logger.error(f"Device {device_name} is already in the list.")
return
item = QtWidgets.QListWidgetItem(self._list_widget)
widget = ValidationListItem(device_name=device_name, device_config=device_config)
# wrap it in a QListWidgetItem
item.setSizeHint(widget.sizeHint())
self._list_widget.addItem(item)
self._list_widget.setItemWidget(item, widget)
self._device_list_items[device_name] = item
self._run_device_validation(widget)
@SafeSlot(dict)
def remove_device_configs(self, device_configs: dict[str, dict]) -> None:
"""Remove device configs from the list.
Args:
device_name (str): The name of the device to remove.
"""
for device_name in device_configs.keys():
if device_name not in self._device_list_items:
logger.warning(f"Device {device_name} not found in list.")
return
self._remove_list_item(device_name)
def _remove_list_item(self, device_name: str):
"""Remove a device from the list."""
# Get the list item
item = self._device_list_items.pop(device_name)
# Retrieve the custom widget attached to the item
widget = self._list_widget.itemWidget(item)
if widget is not None:
widget.deleteLater() # clean up custom widget
# Remove the item from the QListWidget
row = self._list_widget.row(item)
self._list_widget.takeItem(row)
def _run_device_validation(self, widget: ValidationListItem):
"""
Run the device validation in a separate thread.
Args:
widget (ValidationListItem): The widget to validate.
"""
if not READY_TO_TEST:
logger.error("Ophyd devices or bec_server not available, cannot run validation.")
return
if (
widget.device_name in self.client.device_manager.devices
): # TODO and config has to be exact the same..
self._on_device_validated(
widget.device_name,
ValidationStatus.VALID,
f"Device {widget.device_name} is already in active config",
)
return
runnable = DeviceValidationRunnable(
device_name=widget.device_name,
config=widget.device_config,
static_device_test=self.static_device_test,
connect=False,
)
runnable.signals.device_validated.connect(self._on_device_validated)
self._thread_pool.start(runnable)
@SafeSlot(str, bool, str)
def _on_device_validated(self, device_name: str, success: bool, message: str):
"""Handle the device validation result.
Args:
device_name (str): The name of the device.
success (bool): Whether the validation was successful.
message (str): The validation message.
"""
logger.info(f"Device {device_name} validation result: {success}, message: {message}")
item = self._device_list_items.get(device_name, None)
if not item:
logger.error(f"Device {device_name} not found in the list.")
return
if success:
self._remove_list_item(device_name=device_name)
self.device_validated.emit(device_name, ValidationStatus.VALID.value)
else:
widget: ValidationListItem = self._list_widget.itemWidget(item)
widget.on_validation_failed(message)
self.device_validated.emit(device_name, ValidationStatus.FAILED.value)
def _on_current_item_changed(
self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem
):
"""Handle the current item change in the list widget.
Args:
current (QListWidgetItem): The currently selected item.
previous (QListWidgetItem): The previously selected item.
"""
widget: ValidationListItem = self._list_widget.itemWidget(current)
if widget:
try:
formatted_html = self._format_validation_message(widget.validation_msg)
self._text_box.setHtml(formatted_html)
except Exception as e:
logger.error(f"Error formatting validation message: {e}")
self._text_box.setPlainText(widget.validation_msg)
def _format_validation_message(self, raw_msg: str) -> str:
"""Simple HTML formatting for validation messages, wrapping text naturally."""
if not raw_msg.strip():
return "<i>Validation in progress...</i>"
if raw_msg == "Validation in progress...":
return "<i>Validation in progress...</i>"
raw_msg = escape(raw_msg)
# Split into lines
lines = raw_msg.splitlines()
summary = lines[0] if lines else "Validation Result"
rest = "\n".join(lines[1:]).strip()
# Split traceback / final ERROR
tb_match = re.search(r"(Traceback.*|ERROR:.*)$", rest, re.DOTALL | re.MULTILINE)
if tb_match:
main_text = rest[: tb_match.start()].strip()
error_detail = tb_match.group().strip()
else:
main_text = rest
error_detail = ""
# Highlight field names in orange (simple regex for word: Field)
main_text_html = re.sub(
r"(\b\w+\b)(?=: Field required)",
r'<span style="color:#FF8C00; font-weight:bold;">\1</span>',
main_text,
)
# Wrap in div for monospace, allowing wrapping
main_text_html = (
f'<div style="white-space: pre-wrap;">{main_text_html}</div>' if main_text_html else ""
)
# Traceback / error in red
error_html = (
f'<div style="white-space: pre-wrap; color:#A00000;">{error_detail}</div>'
if error_detail
else ""
)
# Summary at top, dark red
html = (
f'<div style="font-family: monospace; font-size:13px; white-space: pre-wrap;">'
f'<div style="font-weight:bold; color:#8B0000; margin-bottom:4px;">{summary}</div>'
f"{main_text_html}"
f"{error_html}"
f"</div>"
)
return html
@SafeSlot()
def clear_list(self):
"""Clear the device list."""
self._thread_pool.clear()
if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish
logger.error("Failed to wait for threads to finish. Removing items from the list.")
self._device_list_items.clear()
self._list_widget.clear()
def remove_device(self, device_name: str):
"""Remove a device from the list."""
item = self._device_list_items.pop(device_name, None)
if item:
self._list_widget.removeItemWidget(item)
if __name__ == "__main__":
import sys
from bec_lib.bec_yaml_loader import yaml_load
# pylint: disable=ungrouped-imports
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
device_manager_ophyd_test = DMOphydTest()
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml"
cfg = yaml_load(config_path)
cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}})
device_manager_ophyd_test.add_device_configs(cfg)
device_manager_ophyd_test.show()
device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test")
device_manager_ophyd_test.resize(800, 600)
sys.exit(app.exec_())

View File

@@ -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.
"""

View File

@@ -20,7 +20,7 @@ from qtpy.QtWidgets import (
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
@@ -45,6 +45,7 @@ class ScanControl(BECWidget, QWidget):
Widget to submit new scans to the queue.
"""
USER_ACCESS = ["attach", "detach", "screenshot"]
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2
@@ -135,13 +136,8 @@ class ScanControl(BECWidget, QWidget):
self.scan_control_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.button_layout = QHBoxLayout(self.scan_control_group)
self.button_run_scan = QPushButton("Start", self.scan_control_group)
self.button_run_scan.setStyleSheet(
f"background-color: {palette.success.name()}; color: white"
)
self.button_run_scan.setProperty("variant", "success")
self.button_stop_scan = StopButton(parent=self.scan_control_group)
self.button_stop_scan.setStyleSheet(
f"background-color: {palette.emergency.name()}; color: white"
)
self.button_layout.addWidget(self.button_run_scan)
self.button_layout.addWidget(self.button_stop_scan)
self.layout.addWidget(self.scan_control_group)
@@ -546,12 +542,10 @@ class ScanControl(BECWidget, QWidget):
# Application example
if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme
app = QApplication([])
scan_control = ScanControl()
set_theme("auto")
apply_theme("dark")
window = scan_control
window.show()
app.exec()

View File

@@ -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

View File

@@ -175,10 +175,10 @@ if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
set_theme("dark")
apply_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()

View File

@@ -249,10 +249,10 @@ class DictBackedTable(QWidget):
if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
set_theme("dark")
apply_theme("dark")
window = DictBackedTable(None, [["key1", "value1"], ["key2", "value2"], ["key3", "value3"]])
window.show()

View File

@@ -1,6 +1,7 @@
from typing import Literal
import qtmonaco
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
@@ -12,11 +13,14 @@ class MonacoWidget(BECWidget, QWidget):
A simple Monaco editor widget
"""
text_changed = Signal(str)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
"set_text",
"get_text",
"insert_text",
"delete_line",
"set_language",
"get_language",
"set_theme",
@@ -25,6 +29,12 @@ class MonacoWidget(BECWidget, QWidget):
"set_cursor",
"current_cursor",
"set_minimap_enabled",
"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):
@@ -36,6 +46,7 @@ class MonacoWidget(BECWidget, QWidget):
self.editor = qtmonaco.Monaco(self)
layout.addWidget(self.editor)
self.setLayout(layout)
self.editor.text_changed.connect(self.text_changed.emit)
self.editor.initialized.connect(self.apply_theme)
def apply_theme(self, theme: str | None = None) -> None:
@@ -65,6 +76,26 @@ class MonacoWidget(BECWidget, QWidget):
"""
return self.editor.get_text()
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
Args:
text (str): The text to insert.
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
"""
self.editor.insert_text(text, line, column)
def delete_line(self, line: int | None = None) -> None:
"""
Delete a line in the Monaco editor.
Args:
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
"""
self.editor.delete_line(line)
def set_cursor(
self,
line: int,
@@ -154,6 +185,34 @@ class MonacoWidget(BECWidget, QWidget):
"""
self.editor.clear_highlighted_lines()
def set_vim_mode_enabled(self, enabled: bool) -> None:
"""
Enable or disable Vim mode in the Monaco editor.
Args:
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
"""
self.editor.set_vim_mode_enabled(enabled)
def set_lsp_header(self, header: str) -> None:
"""
Set the LSP (Language Server Protocol) header for the Monaco editor.
The header is used to provide context for language servers but is not displayed in the editor.
Args:
header (str): The LSP header to set.
"""
self.editor.set_lsp_header(header)
def get_lsp_header(self) -> str:
"""
Get the current LSP header set in the Monaco editor.
Returns:
str: The LSP header.
"""
return self.editor.get_lsp_header()
if __name__ == "__main__": # pragma: no cover
qapp = QApplication([])

View File

@@ -97,7 +97,7 @@ if __name__ == "__main__": # pragma: no cover
from bec_lib.metadata_schema import BasicScanMetadata
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
class ExampleSchema1(BasicScanMetadata):
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
@@ -141,7 +141,7 @@ if __name__ == "__main__": # pragma: no cover
layout.addWidget(selection)
layout.addWidget(scan_metadata)
set_theme("dark")
apply_theme("dark")
window = w
window.show()
app.exec()

View File

@@ -6,11 +6,12 @@ import time
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import QUrl, qInstallMessageHandler
from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
logger = bec_logger.logger
@@ -165,11 +166,16 @@ class WebConsole(BECWidget, QWidget):
A simple widget to display a website
"""
_js_callback = Signal(bool)
initialized = Signal()
PLUGIN = True
ICON_NAME = "terminal"
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._startup_cmd = "bec --nogui"
self._is_initialized = False
_web_console_registry.register(self)
self._token = _web_console_registry._token
layout = QVBoxLayout()
@@ -181,6 +187,48 @@ class WebConsole(BECWidget, QWidget):
layout.addWidget(self.browser)
self.setLayout(layout)
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
self._startup_timer = QTimer()
self._startup_timer.setInterval(500)
self._startup_timer.timeout.connect(self._check_page_ready)
self._startup_timer.start()
self._js_callback.connect(self._on_js_callback)
def _check_page_ready(self):
"""
Check if the page is ready and stop the timer if it is.
"""
if self.page.isLoading():
return
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
def _on_js_callback(self, ready: bool):
"""
Callback for when the JavaScript is ready.
"""
if not ready:
return
self._is_initialized = True
self._startup_timer.stop()
if self._startup_cmd:
self.write(self._startup_cmd)
self.initialized.emit()
@SafeProperty(str)
def startup_cmd(self):
"""
Get the startup command for the web console.
"""
return self._startup_cmd
@startup_cmd.setter
def startup_cmd(self, cmd: str):
"""
Set the startup command for the web console.
"""
if not isinstance(cmd, str):
raise ValueError("Startup command must be a string.")
self._startup_cmd = cmd
def write(self, data: str, send_return: bool = True):
"""
@@ -213,10 +261,19 @@ class WebConsole(BECWidget, QWidget):
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
)
def set_readonly(self, readonly: bool):
"""
Set the web console to read-only mode.
"""
if not isinstance(readonly, bool):
raise ValueError("Readonly must be a boolean.")
self.setEnabled(not readonly)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
self._startup_timer.stop()
_web_console_registry.unregister(self)
super().cleanup()

View File

@@ -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

View File

@@ -407,10 +407,10 @@ class Minesweeper(BECWidget, QWidget):
if __name__ == "__main__":
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication([])
set_theme("light")
apply_theme("light")
widget = Minesweeper()
widget.show()

View File

@@ -115,6 +115,9 @@ class Heatmap(ImageBase):
"auto_range_y.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"attach",
"detach",
"screenshot",
# ImageView Specific Settings
"color_map",
"color_map.setter",

View File

@@ -91,6 +91,9 @@ class Image(ImageBase):
"auto_range_y.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"attach",
"detach",
"screenshot",
# ImageView Specific Settings
"color_map",
"color_map.setter",

View File

@@ -11,7 +11,7 @@ from qtpy.QtGui import QColor
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
@@ -128,6 +128,9 @@ class MotorMap(PlotBase):
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"attach",
"detach",
"screenshot",
# motor_map specific
"color",
"color.setter",
@@ -827,7 +830,7 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
set_theme("dark")
apply_theme("dark")
widget = DemoApp()
widget.show()
widget.resize(1400, 600)

View File

@@ -96,6 +96,9 @@ class MultiWaveform(PlotBase):
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"attach",
"detach",
"screenshot",
# MultiWaveform Specific RPC Access
"highlighted_index",
"highlighted_index.setter",

View File

@@ -134,7 +134,7 @@ class PlotBase(BECWidget, QWidget):
self._init_ui()
self._connect_to_theme_change()
self._update_theme()
self._update_theme(None)
def apply_theme(self, theme: str):
self.round_plot_widget.apply_theme(theme)
@@ -142,6 +142,8 @@ class PlotBase(BECWidget, QWidget):
def _init_ui(self):
self.layout.addWidget(self.layout_manager)
self.round_plot_widget = RoundedFrame(parent=self, content_widget=self.plot_widget)
self.round_plot_widget.setProperty("variant", "plot_background")
self.round_plot_widget.setProperty("frameless", True)
self.layout_manager.add_widget(self.round_plot_widget)
self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top")

View File

@@ -10,7 +10,6 @@ from qtpy.QtCore import QTimer, Signal
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
@@ -84,6 +83,9 @@ class ScatterWaveform(PlotBase):
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"attach",
"detach",
"screenshot",
# Scatter Waveform Specific RPC Access
"main_curve",
"color_map",
@@ -543,8 +545,10 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
set_theme("dark")
apply_theme("dark")
widget = DemoApp()
widget.show()
widget.resize(1400, 600)

View File

@@ -7,6 +7,7 @@ from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QHBoxLayout,
QHeaderView,
@@ -70,6 +71,7 @@ class CurveRow(QTreeWidgetItem):
# A top-level device row.
super().__init__(tree)
self.app = QApplication.instance()
self.tree = tree
self.parent_item = parent_item
self.curve_tree = tree.parent() # The CurveTree widget
@@ -115,7 +117,16 @@ class CurveRow(QTreeWidgetItem):
# If device row, add "Add DAP" button
if self.source == "device":
self.add_dap_button = QPushButton("DAP")
self.add_dap_button = QToolButton()
analysis_icon = material_icon(
"monitoring",
size=(20, 20),
convert_to_pixmap=False,
filled=False,
color=self.app.theme.colors["FG"].toTuple(),
)
self.add_dap_button.setIcon(analysis_icon)
self.add_dap_button.setToolTip("Add DAP")
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
actions_layout.addWidget(self.add_dap_button)

View File

@@ -25,7 +25,7 @@ from qtpy.QtWidgets import (
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
from bec_widgets.utils.colors import Colors, set_theme
from bec_widgets.utils.colors import Colors, apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
@@ -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",
@@ -2055,7 +2059,7 @@ if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
set_theme("dark")
apply_theme("dark")
widget = DemoApp()
widget.show()
widget.resize(1400, 600)

View File

@@ -96,6 +96,9 @@ class RingProgressBar(BECWidget, QWidget):
"set_diameter",
"reset_diameter",
"enable_auto_updates",
"attach",
"detach",
"screenshot",
]
def __init__(

View File

@@ -242,8 +242,15 @@ class BECQueue(BECWidget, CompactPopupWidget):
abort_button.button.setIcon(
material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False)
)
abort_button.button.setStyleSheet("background-color: rgba(0,0,0,0) ")
abort_button.button.setFlat(True)
abort_button.setStyleSheet(
"""
QPushButton {
background-color: transparent;
border: none;
}
"""
)
return abort_button
def delete_selected_row(self):

View File

@@ -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)
@@ -315,10 +315,10 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
set_theme("dark")
apply_theme("dark")
main_window = BECStatusBox()
main_window.show()
sys.exit(app.exec())

View File

@@ -240,10 +240,10 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
set_theme("light")
apply_theme("light")
widget = DeviceBrowser()
widget.show()
sys.exit(app.exec_())

View File

@@ -262,12 +262,12 @@ def main(): # pragma: no cover
from qtpy.QtWidgets import QApplication, QLineEdit, QPushButton, QWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
dialog = None
app = QApplication(sys.argv)
set_theme("light")
apply_theme("light")
widget = QWidget()
widget.setLayout(QVBoxLayout())

View File

@@ -110,10 +110,10 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
app = QApplication(sys.argv)
set_theme("light")
apply_theme("light")
widget = SignalDisplay(device="samx")
widget.show()
sys.exit(app.exec_())

View File

@@ -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.")

View 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_()

View File

@@ -0,0 +1 @@
{'files': ['ide_explorer.py']}

View File

@@ -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()

View File

@@ -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()

View File

@@ -35,7 +35,7 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.colors import get_theme_palette, set_theme
from bec_widgets.utils.colors import apply_theme, get_theme_palette
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.editors.text_box.text_box import TextBox
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECServiceStatusMixin
@@ -544,7 +544,7 @@ if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports
app = QApplication(sys.argv)
set_theme("dark")
apply_theme("dark")
widget = LogPanel()
widget.show()

View File

@@ -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())

View File

@@ -49,7 +49,7 @@ class SpinnerWidget(QWidget):
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
size = min(self.width(), self.height())
rect = QRect(0, 0, size, size)
@@ -63,14 +63,14 @@ class SpinnerWidget(QWidget):
rect.adjust(line_width, line_width, -line_width, -line_width)
# Background arc
painter.setPen(QPen(background_color, line_width, Qt.SolidLine))
painter.setPen(QPen(background_color, line_width, Qt.PenStyle.SolidLine))
adjusted_rect = QRect(rect.left(), rect.top(), rect.width(), rect.height())
painter.drawArc(adjusted_rect, 0, 360 * 16)
if self._started:
# Foreground arc
pen = QPen(color, line_width, Qt.SolidLine)
pen.setCapStyle(Qt.RoundCap)
pen = QPen(color, line_width, Qt.PenStyle.SolidLine)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
proportion = 1 / 4
angle_span = int(proportion * 360 * 16)

View File

@@ -5,7 +5,7 @@ from qtpy.QtCore import Property, Qt, Slot
from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
class DarkModeButton(BECWidget, QWidget):
@@ -85,7 +85,7 @@ class DarkModeButton(BECWidget, QWidget):
"""
self.dark_mode_enabled = not self.dark_mode_enabled
self.update_mode_button()
set_theme("dark" if self.dark_mode_enabled else "light")
apply_theme("dark" if self.dark_mode_enabled else "light")
def update_mode_button(self):
icon = material_icon(
@@ -100,7 +100,7 @@ class DarkModeButton(BECWidget, QWidget):
if __name__ == "__main__":
app = QApplication([])
set_theme("auto")
apply_theme("dark")
w = DarkModeButton()
w.show()

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.31.3"
version = "2.38.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -13,17 +13,19 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
"bec_lib>=3.44, <=4.0",
"bec_qthemes~=0.7, >=0.7",
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"bec_ipython_client~=3.52", # needed for jupyter console
"bec_lib~=3.52",
"bec_qthemes~=1.0, >=1.1.2",
"black~=25.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli
"pydantic~=2.0",
"pyqtgraph~=0.13",
"PySide6~=6.8.2",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"PySide6==6.9.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
"qtmonaco>=0.2.3",
"qtmonaco~=0.5",
"darkdetect~=0.8",
"PySide6-QtAds==4.4.0",
]
@@ -39,6 +41,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]

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