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

Compare commits

...

41 Commits

Author SHA1 Message Date
fcefe9e33d wip push and autohide for docks 2025-09-04 14:16:29 +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
71 changed files with 3312 additions and 375 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,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

@@ -106,6 +106,99 @@ class AbortButton(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class AdvancedDockArea(RPCBase):
@rpc_call
def new(
self,
widget: "BECWidget | str",
closable: "bool" = True,
floatable: "bool" = True,
movable: "bool" = True,
start_floating: "bool" = False,
where: "Literal['left', 'right', 'top', 'bottom'] | None" = None,
) -> "BECWidget":
"""
Create a new widget (or reuse an instance) and add it as a dock.
Args:
widget: Widget instance or a string widget type (factory-created).
closable: Whether the dock is closable.
floatable: Whether the dock is floatable.
movable: Whether the dock is movable.
start_floating: Start the dock in a floating state.
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
If None, uses the instance default passed at construction time.
Returns:
The widget instance.
"""
@rpc_call
def widget_map(self) -> "dict[str, QWidget]":
"""
Return a dictionary mapping widget names to their corresponding BECWidget instances.
Returns:
dict: A dictionary mapping widget names to BECWidget instances.
"""
@rpc_call
def widget_list(self) -> "list[QWidget]":
"""
Return a list of all BECWidget instances in the dock area.
Returns:
list: A list of all BECWidget instances in the dock area.
"""
@property
@rpc_call
def lock_workspace(self) -> "bool":
"""
Get or set the lock state of the workspace.
Returns:
bool: True if the workspace is locked, False otherwise.
"""
@rpc_call
def attach_all(self):
"""
Return all floating docks to the dock area, preserving tab groups within each floating container.
"""
@rpc_call
def delete_all(self):
"""
Delete all docks and widgets.
"""
@property
@rpc_call
def mode(self) -> "str":
"""
None
"""
@mode.setter
@rpc_call
def mode(self) -> "str":
"""
None
"""
class AutoUpdates(RPCBase):
@property
@@ -442,6 +535,18 @@ class BECMainWindow(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class BECProgressBar(RPCBase):
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
@@ -525,6 +630,18 @@ class BECQueue(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class BECStatusBox(RPCBase):
"""An autonomous widget to display the status of BEC services."""
@@ -541,6 +658,25 @@ class BECStatusBox(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class BaseROI(RPCBase):
"""Base class for all Region of Interest (ROI) implementations."""
@@ -1002,6 +1138,18 @@ class DarkModeButton(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class DeviceBrowser(RPCBase):
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
@@ -1012,6 +1160,18 @@ class DeviceBrowser(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class DeviceComboBox(RPCBase):
"""Combobox widget for device input with autocomplete for device names."""
@@ -1045,6 +1205,18 @@ class DeviceInputBase(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class DeviceLineEdit(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""
@@ -1433,6 +1605,18 @@ class Heatmap(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -1978,6 +2162,18 @@ class Image(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -2590,6 +2786,25 @@ class MonacoWidget(RPCBase):
str: The LSP header.
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
@@ -2865,6 +3080,18 @@ class MotorMap(RPCBase):
The font size of the legend font.
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -3277,6 +3504,18 @@ class MultiWaveform(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -3498,6 +3737,18 @@ class PositionerBox(RPCBase):
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -3527,6 +3778,18 @@ class PositionerBox2D(RPCBase):
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -3547,6 +3810,18 @@ class PositionerControlLine(RPCBase):
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -3566,6 +3841,25 @@ class PositionerGroup(RPCBase):
Device names must be separated by space
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class RectangularROI(RPCBase):
"""Defines a rectangular Region of Interest (ROI) with additional functionality."""
@@ -3705,6 +3999,18 @@ class ResetButton(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class ResumeButton(RPCBase):
"""A button that continue scan queue."""
@@ -3715,6 +4021,18 @@ class ResumeButton(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class Ring(RPCBase):
@rpc_call
@@ -3996,6 +4314,25 @@ class RingProgressBar(RPCBase):
bool: True if scan segment updates are enabled.
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class SBBMonitor(RPCBase):
"""A widget to display the SBB monitor website."""
@@ -4007,9 +4344,15 @@ class ScanControl(RPCBase):
"""Widget to submit new scans to the queue."""
@rpc_call
def remove(self):
def attach(self):
"""
Cleanup the BECConnector
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@@ -4029,6 +4372,18 @@ class ScanProgressBar(RPCBase):
Cleanup the BECConnector
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
class ScatterCurve(RPCBase):
"""Scatter curve item for the scatter waveform widget."""
@@ -4327,6 +4682,18 @@ class ScatterWaveform(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_call
def attach(self):
"""
None
"""
@rpc_call
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
@@ -4629,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"""
@@ -4661,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":
@@ -4965,13 +5363,6 @@ class Waveform(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def curves(self) -> "list[Curve]":
@@ -5213,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"""
@@ -5252,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

@@ -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,98 @@
from typing import List
import PySide6QtAds as QtAds
from PySide6.QtWidgets import QTableWidget, QListWidget, QTableWidgetItem, QPushButton
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QSplitter, QTreeWidget, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
class AutoHideOverlay(QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
# IMPORTANT, if you decide to use autohide, you must set these flags before creating ANY CDockManager, it has to be put into inity, let me know and I can enforce it
CDockManager.setAutoHideConfigFlags(CDockManager.DefaultAutoHideConfig)
CDockManager.setAutoHideConfigFlag(
CDockManager.DockAreaHasAutoHideButton, False
) # to not have everywhere these buttons
self.dock_manager = CDockManager(self)
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.left_widget = QWidget(self)
self.left_widget.layout = QVBoxLayout(self.left_widget)
self.auto_hide_controls = QPushButton("Auto Hide Controls", self.left_widget)
self.auto_hide_controls.setCheckable(True)
self.tree_widget = QTreeWidget(self)
self.left_widget.layout.addWidget(self.auto_hide_controls)
self.left_widget.layout.addWidget(self.tree_widget)
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
# table with some data
self.table = QTableWidget(10, 3, self)
self.table.setHorizontalHeaderLabels(["Column 1", "Column 2", "Column 3"])
for row in range(10):
for col in range(3):
self.table.setItem(row, col, QTableWidgetItem(f"Item {row+1}, {col+1}"))
self.list_widget = QListWidget(self)
for i in range(10):
self.list_widget.addItem(f"List Item {i+1}")
# Create the dock widgets
self.tree_dock = QtAds.CDockWidget("Explorer", self)
self.tree_dock.setWidget(self.left_widget)
self.table_dock = QtAds.CDockWidget("Table", self)
self.table_dock.setWidget(self.table)
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
self.plotting_ads_dock.setWidget(self.plotting_ads)
# this one will be autohide one
self.list_dock = QtAds.CDockWidget("List Widget", self)
self.list_dock.setWidget(self.list_widget)
# Monaco will be central widget
self.dock_manager.setCentralWidget(self.plotting_ads_dock)
# Add the dock widgets to the dock manager
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.BottomDockWidgetArea, self.table_dock)
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.tree_dock)
self.autohide_container = self.dock_manager.addAutoHideDockWidget(
QtAds.SideBarRight, self.list_dock
)
self.autohide_container.setSize(350)
# Connect signals
self.auto_hide_controls.toggled.connect(self.toggle_auto_hide)
def toggle_auto_hide(self, checked: bool):
# start as collapsed, implement better logic
# self.autohide_container.collapseView(checked)
# or this if you just want toggle
self.autohide_container.toggleCollapseState()
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
auto_hide_overlay = AutoHideOverlay()
auto_hide_overlay.show()
auto_hide_overlay.resize(1200, 800)
sys.exit(app.exec())

View File

@@ -0,0 +1,123 @@
from typing import Optional
import PySide6QtAds as QtAds
from PySide6.QtWidgets import QTableWidget, QListWidget, QTableWidgetItem, QPushButton
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import QSplitter, QTreeWidget, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
class AutoHidePush(QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
# CDockManager.setConfigFlag(CDockManager.FocusHighlighting, True)
CDockManager.setAutoHideConfigFlags(CDockManager.DefaultAutoHideConfig)
# CDockManager.setAutoHideConfigFlag(CDockManager.AutoHideShowOnMouseOver, True)
self.dock_manager = CDockManager(self)
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.left_widget = QWidget(self)
self.left_widget.layout = QVBoxLayout(self.left_widget)
self.push_mode_btn = QPushButton(self.left_widget)
self.push_mode_btn.setCheckable(True)
self.push_mode_btn.setText("Pin (show and push)")
self.push_mode_btn.setChecked(False)
self.tree_widget = QTreeWidget(self)
self.left_widget.layout.addWidget(self.push_mode_btn)
self.left_widget.layout.addWidget(self.tree_widget)
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
# table with some data
self.table = QTableWidget(10, 3, self)
self.table.setHorizontalHeaderLabels(["Column 1", "Column 2", "Column 3"])
for row in range(10):
for col in range(3):
self.table.setItem(row, col, QTableWidgetItem(f"Item {row+1}, {col+1}"))
self.list_widget = QListWidget(self)
for i in range(10):
self.list_widget.addItem(f"List Item {i+1}")
# Create the dock widgets
self.tree_dock = QtAds.CDockWidget("Explorer", self)
self.tree_dock.setWidget(self.left_widget)
self.table_dock = QtAds.CDockWidget("Table", self)
self.table_dock.setWidget(self.table)
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
self.plotting_ads_dock.setWidget(self.plotting_ads)
# this one will be autohide one
self.list_dock = QtAds.CDockWidget("List Widget", self)
self.list_dock.setWidget(self.list_widget)
# Monaco will be central widget
self.dock_manager.setCentralWidget(self.plotting_ads_dock)
# Add the dock widgets to the dock manager
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.BottomDockWidgetArea, self.table_dock)
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.tree_dock)
self.autohide_container = self.dock_manager.addAutoHideDockWidget(
QtAds.SideBarRight, self.list_dock
)
self.autohide_container.setSize(350)
self._last_side = QtAds.SideBarRight
# Ensure auto-hide starts collapsed and button text is correct
self.autohide_container.collapseView(True)
self.push_mode_btn.setText("Pin (show and push)")
# Connect signals
self.push_mode_btn.toggled.connect(self.toggle_pin_mode)
def toggle_pin_mode(self, pinned: bool):
# pinned=True -> convert auto-hide overlay into a normal dock (push layout) and show it
if pinned:
if self.autohide_container is not None:
# Remember the current side (edge) to restore when unpinning
if hasattr(self.autohide_container, "sideBarLocation"):
self._last_side = self.autohide_container.sideBarLocation()
# Move contents back into the dock container (this deletes the auto-hide container)
self.autohide_container.moveContentsToParent()
self.autohide_container = None
# Ensure the dock is visible when pinned
self.list_dock.show()
self.push_mode_btn.setText("Unpin (send to sidebar)")
else:
# Convert the pinned dock back into an auto-hide overlay at the last used side and collapse it
side = getattr(self, "_last_side", QtAds.SideBarRight)
container = self.dock_manager.addAutoHideDockWidget(side, self.list_dock)
# Preserve a sensible size from the current dock widget geometry
if side in (QtAds.SideBarLeft, QtAds.SideBarRight):
target_size = max(200, min(self.list_dock.width(), 600))
else:
target_size = max(200, min(self.list_dock.height(), 600))
container.setSize(int(target_size))
self.autohide_container = container
# Collapse so it disappears to the side tab
self.autohide_container.collapseView(True)
self.push_mode_btn.setText("Pin (show and push)")
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
apply_theme("dark")
auto_hide_overlay = AutoHidePush()
auto_hide_overlay.show()
auto_hide_overlay.resize(1200, 800)
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

@@ -3,7 +3,7 @@ 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
@@ -11,9 +11,9 @@ 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 SafeSlot
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
@@ -27,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__(
@@ -45,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:
@@ -62,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()
@@ -78,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:
@@ -124,6 +116,26 @@ class BECWidget(BECConnector):
screenshot.save(file_name)
logger.info(f"Screenshot saved to {file_name}")
def attach(self):
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
if dock is None:
return
if not dock.isFloating():
return
dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock)
def detach(self):
"""
Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget.
"""
dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget)
if dock is None:
return
if dock.isFloating():
return
dock.setFloating()
def cleanup(self):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():

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

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

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

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

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

@@ -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", "screenshot"]
USER_ACCESS = ["set_positioner", "attach", "detach", "screenshot"]
device_changed = Signal(str, str)
# Signal emitted to inform listeners about a position update
position_update = Signal(float)
@@ -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", "screenshot"]
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

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

@@ -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,7 +45,7 @@ class ScanControl(BECWidget, QWidget):
Widget to submit new scans to the queue.
"""
USER_ACCESS = ["remove", "screenshot"]
USER_ACCESS = ["attach", "detach", "screenshot"]
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2
@@ -136,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)
@@ -547,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

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

@@ -32,6 +32,9 @@ class MonacoWidget(BECWidget, QWidget):
"set_vim_mode_enabled",
"set_lsp_header",
"get_lsp_header",
"attach",
"detach",
"screenshot",
]
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):

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

@@ -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,8 @@ class Heatmap(ImageBase):
"auto_range_y.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"attach",
"detach",
"screenshot",
# ImageView Specific Settings
"color_map",

View File

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

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,8 @@ class MotorMap(PlotBase):
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"attach",
"detach",
"screenshot",
# motor_map specific
"color",
@@ -828,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,8 @@ class MultiWaveform(PlotBase):
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"attach",
"detach",
"screenshot",
# MultiWaveform Specific RPC Access
"highlighted_index",

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,8 @@ class ScatterWaveform(PlotBase):
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"attach",
"detach",
"screenshot",
# Scatter Waveform Specific RPC Access
"main_curve",
@@ -544,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",
@@ -105,7 +109,6 @@ class Waveform(PlotBase):
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
# Waveform Specific RPC Access
"curves",
"x_mode",
@@ -2056,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

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

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

@@ -13,17 +13,19 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client~=3.52", # needed for jupyter console
"bec_ipython_client~=3.52", # needed for jupyter console
"bec_lib~=3.52",
"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_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.9.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtpy~=2.4",
"qtmonaco~=0.5",
"darkdetect~=0.8",
"PySide6-QtAds==4.4.0",
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -5,7 +5,9 @@ import h5py
import numpy as np
import pytest
from bec_lib import messages
from bec_qthemes import apply_theme
from pytestqt.exceptions import TimeoutError as QtBotTimeoutError
from qtpy.QtCore import QEvent, QEventLoop
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
@@ -22,8 +24,18 @@ def pytest_runtest_makereport(item, call):
item.stash["failed"] = rep.failed
def process_all_deferred_deletes(qapp):
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
qapp.processEvents(QEventLoop.AllEvents)
@pytest.fixture(autouse=True)
def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument
qapp = QApplication.instance()
process_all_deferred_deletes(qapp)
apply_theme("light")
qapp.processEvents()
yield
# if the test failed, we don't want to check for open widgets as
@@ -35,7 +47,6 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus
bec_dispatcher.stop_cli_server()
testable_qtimer_class.check_all_stopped(qtbot)
qapp = QApplication.instance()
qapp.processEvents()
if hasattr(qapp, "os_listener") and qapp.os_listener:
qapp.removeEventFilter(qapp.os_listener)

View File

@@ -17,10 +17,6 @@ def abort_button(qtbot, mocked_client):
def test_abort_button(abort_button):
assert abort_button.button.text() == "Abort"
assert (
abort_button.button.styleSheet()
== "background-color: #666666; color: white; font-weight: bold; font-size: 12px;"
)
abort_button.button.click()
assert abort_button.queue.request_scan_abortion.called
abort_button.close()

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
# pylint: disable=unused-import
@@ -21,7 +21,7 @@ def dark_mode_button(qtbot, mocked_client):
button = DarkModeButton(client=mocked_client)
qtbot.addWidget(button)
qtbot.waitExposed(button)
set_theme("light")
apply_theme("light")
yield button
@@ -64,23 +64,10 @@ def test_dark_mode_button_changes_theme(dark_mode_button):
Test that the dark mode button changes the theme correctly.
"""
with mock.patch(
"bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.set_theme"
) as mocked_set_theme:
"bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.apply_theme"
) as mocked_apply_theme:
dark_mode_button.toggle_dark_mode()
mocked_set_theme.assert_called_with("dark")
mocked_apply_theme.assert_called_with("dark")
dark_mode_button.toggle_dark_mode()
mocked_set_theme.assert_called_with("light")
def test_dark_mode_button_changes_on_os_theme_change(qtbot, dark_mode_button):
"""
Test that the dark mode button changes the theme correctly when the OS theme changes.
"""
qapp = QApplication.instance()
assert dark_mode_button.dark_mode_enabled is False
assert dark_mode_button.mode_button.toolTip() == "Set Dark Mode"
qapp.theme_signal.theme_updated.emit("dark")
qtbot.wait(100)
assert dark_mode_button.dark_mode_enabled is True
assert dark_mode_button.mode_button.toolTip() == "Set Light Mode"
mocked_apply_theme.assert_called_with("light")

View File

@@ -42,18 +42,6 @@ def test_set_radius(basic_rounded_frame):
assert basic_rounded_frame.radius == 20
def test_apply_theme_light(plot_rounded_frame):
plot_rounded_frame.apply_theme("light")
assert plot_rounded_frame.background_color == "#e9ecef"
def test_apply_theme_dark(plot_rounded_frame):
plot_rounded_frame.apply_theme("dark")
assert plot_rounded_frame.background_color == "#141414"
def test_apply_plot_widget_style(plot_rounded_frame):
# Verify that a PlotWidget can have its style applied
plot_rounded_frame.apply_plot_widget_style(border="1px solid red")

View File

@@ -21,7 +21,6 @@ def test_multiple_extension_registration():
"""
Test that multiple extension registrations do not cause issues.
"""
assert serialization.module_is_registered("bec_widgets.utils.serialization")
assert msgpack.is_registered(QPointF)
serialization.register_serializer_extension()
assert serialization.module_is_registered("bec_widgets.utils.serialization")
assert len(msgpack._encoder) == len(set(msgpack._encoder))
assert msgpack.is_registered(QPointF)

View File

@@ -17,10 +17,6 @@ def stop_button(qtbot, mocked_client):
def test_stop_button(stop_button):
assert stop_button.button.text() == "Stop"
assert (
stop_button.button.styleSheet()
== "background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;"
)
stop_button.button.click()
assert stop_button.queue.request_scan_halt.called
stop_button.close()