From b092d2a0940e36a9d65160be229e42f874206c0b Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 7 Aug 2025 16:36:53 +0200 Subject: [PATCH 01/45] build: PySide6-QtAds dependency added --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 625f14dd9..7c765887b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "qtpy~=2.4", "qtmonaco~=0.5", "thefuzz~=0.22", + "PySide6-QtAds==4.4.0", ] From df9b5b7588218a9dfb4915224f6ffa1ce28ae53b Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 5 Aug 2025 16:50:14 +0200 Subject: [PATCH 02/45] fix(bec_connector): dedicated remove signal added for listeners --- bec_widgets/utils/bec_connector.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index c04b3fd33..289be6366 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -77,6 +77,7 @@ class BECConnector: USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"] EXIT_HANDLERS = {} + remove_signal = Signal() def __init__( self, @@ -450,6 +451,7 @@ def remove(self): # i.e. Curve Item from Waveform else: self.rpc_register.remove_rpc(self) + self.remove_signal.emit() # Emit the remove signal to notify listeners (eg docks in QtADS) def get_config(self, dict_output: bool = True) -> dict | BaseModel: """ From c20a1ac865acc47bdcae03c20abfa1b2ec01a844 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 21:37:34 +0200 Subject: [PATCH 03/45] fix(bec_connector): added name established signal for listeners --- bec_widgets/utils/bec_connector.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 289be6366..46d908a15 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -78,6 +78,7 @@ class BECConnector: USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"] EXIT_HANDLERS = {} remove_signal = Signal() + name_established_signal = Signal(str) def __init__( self, @@ -205,6 +206,10 @@ def _update_object_name(self) -> None: self._enforce_unique_sibling_name() # 2) Register the object for RPC self.rpc_register.add_rpc(self) + try: + self.name_established_signal.emit(self.object_name) + except RuntimeError: + return def _enforce_unique_sibling_name(self): """ From bfc72e86e805b6eb63d9b2a1ba75d35870a3c293 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 19 Aug 2025 11:28:52 +0200 Subject: [PATCH 04/45] refactor(bec_connector): signals renamed --- bec_widgets/utils/bec_connector.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 46d908a15..e5f525d35 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -77,8 +77,8 @@ class BECConnector: USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"] EXIT_HANDLERS = {} - remove_signal = Signal() - name_established_signal = Signal(str) + widget_removed = Signal() + name_established = Signal(str) def __init__( self, @@ -207,7 +207,7 @@ def _update_object_name(self) -> None: # 2) Register the object for RPC self.rpc_register.add_rpc(self) try: - self.name_established_signal.emit(self.object_name) + self.name_established.emit(self.object_name) except RuntimeError: return @@ -456,7 +456,7 @@ def remove(self): # i.e. Curve Item from Waveform else: self.rpc_register.remove_rpc(self) - self.remove_signal.emit() # Emit the remove signal to notify listeners (eg docks in QtADS) + 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: """ From bc8a3282db712f7301703c6ea5940453ca852c54 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 20:24:40 +0200 Subject: [PATCH 05/45] feat(widget_io): widget hierarchy can grap all bec connectors from the widget recursively --- bec_widgets/utils/widget_io.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bec_widgets/utils/widget_io.py b/bec_widgets/utils/widget_io.py index 22a754d9a..1f4243cb0 100644 --- a/bec_widgets/utils/widget_io.py +++ b/bec_widgets/utils/widget_io.py @@ -553,6 +553,20 @@ def import_config_from_dict(widget, config: dict, set_values: bool = False) -> N 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 that are descendants of the given widget, + including the widget itself if it is a BECConnector. + """ + from bec_widgets.utils import BECConnector + + connectors = [] + if isinstance(widget, BECConnector): + connectors.append(widget) + connectors.extend(widget.findChildren(BECConnector)) + return connectors + # Example usage def hierarchy_example(): # pragma: no cover From 02887d2d9a1a95f022b396b77b9fb51630a753a2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 7 Aug 2025 16:05:12 +0200 Subject: [PATCH 06/45] feat(widget_io): widget hierarchy find_ancestor added --- bec_widgets/utils/widget_io.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bec_widgets/utils/widget_io.py b/bec_widgets/utils/widget_io.py index 1f4243cb0..d330a7d9e 100644 --- a/bec_widgets/utils/widget_io.py +++ b/bec_widgets/utils/widget_io.py @@ -567,6 +567,34 @@ def get_bec_connectors_from_parent(widget) -> list: connectors.extend(widget.findChildren(BECConnector)) 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. + """ + + parent = getattr(widget, "parent", None) + # First call parent() if widget has that method + if callable(parent): + parent = parent() + # Otherwise assume widget.parent attribute + while parent is not None and isinstance(parent, QWidget): + try: + if isinstance(ancestor_class, str): + if parent.__class__.__name__ == ancestor_class: + return parent + else: + if isinstance(parent, ancestor_class): + return parent + except Exception: + # In case ancestor_class isn't a valid type or parent.inspect fails + pass + # Move up one level + parent = parent.parent() if hasattr(parent, "parent") else None + return None + # Example usage def hierarchy_example(): # pragma: no cover From d7a946e4320e97da79360da8ab097ddde57a1e1a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 19 Aug 2025 11:51:25 +0200 Subject: [PATCH 07/45] refactor(widget_io): ancestor hierarchy methods consolidated --- bec_widgets/utils/widget_io.py | 42 ++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/bec_widgets/utils/widget_io.py b/bec_widgets/utils/widget_io.py index d330a7d9e..92c9f295d 100644 --- a/bec_widgets/utils/widget_io.py +++ b/bec_widgets/utils/widget_io.py @@ -465,13 +465,19 @@ def _get_becwidget_ancestor(widget): """ 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 @@ -556,15 +562,17 @@ def import_config_from_dict(widget, config: dict, set_values: bool = False) -> N @staticmethod def get_bec_connectors_from_parent(widget) -> list: """ - Return all BECConnector instances that are descendants of the given widget, + 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 = [] + connectors: list[BECConnector] = [] if isinstance(widget, BECConnector): connectors.append(widget) - connectors.extend(widget.findChildren(BECConnector)) + for child in widget.findChildren(BECConnector): + if WidgetHierarchy._get_becwidget_ancestor(child) is widget: + connectors.append(child) return connectors @staticmethod @@ -574,13 +582,29 @@ def find_ancestor(widget, ancestor_class) -> QWidget | None: 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) - # First call parent() if widget has that method if callable(parent): parent = parent() - # Otherwise assume widget.parent attribute - while parent is not None and isinstance(parent, QWidget): + while parent is not None: + if not shb.isValid(parent): + return None try: if isinstance(ancestor_class, str): if parent.__class__.__name__ == ancestor_class: @@ -589,9 +613,7 @@ def find_ancestor(widget, ancestor_class) -> QWidget | None: if isinstance(parent, ancestor_class): return parent except Exception: - # In case ancestor_class isn't a valid type or parent.inspect fails pass - # Move up one level parent = parent.parent() if hasattr(parent, "parent") else None return None From 0a4d3b5818cb4d63ca13b1269a63f84d6dc7db05 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 20:25:01 +0200 Subject: [PATCH 08/45] fix(widget_state_manager): state manager can save all properties recursively --- bec_widgets/utils/widget_state_manager.py | 35 +++++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 9537097c2..1c3e42202 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -15,6 +15,8 @@ QWidget, ) +from bec_widgets.utils.widget_io import WidgetHierarchy + logger = bec_logger.logger @@ -59,13 +61,16 @@ def load_state(self, filename: str = None): settings = QSettings(filename, QSettings.IniFormat) self._load_widget_state_qsettings(self.widget, settings) - 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 +93,32 @@ def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings): 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 +134,21 @@ def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings): 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): """ From 2eb04e0ffad45445258cf99c111a2ea96833aef0 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 20:29:07 +0200 Subject: [PATCH 09/45] fix(widget_state_manager): state manager can save to already existing settings wip widget state manager saving loading file logic --- bec_widgets/utils/widget_state_manager.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 1c3e42202..2efe56e78 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -31,35 +31,47 @@ 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, recursive: bool = True From 4495cc77dbfe2016fc3a47b7c9855b327c421323 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 7 Aug 2025 16:05:43 +0200 Subject: [PATCH 10/45] feat(bec_widget): attach/detach method for all widgets + client regenerated --- bec_widgets/cli/client.py | 359 +++++++++++++++++- bec_widgets/utils/bec_widget.py | 24 +- .../positioner_box/positioner_box.py | 2 +- .../positioner_box_2d/positioner_box_2d.py | 2 +- .../positioner_group/positioner_group.py | 2 +- .../control/scan_control/scan_control.py | 2 +- .../widgets/editors/monaco/monaco_widget.py | 3 + .../widgets/editors/website/website.py | 11 +- bec_widgets/widgets/plots/heatmap/heatmap.py | 2 + bec_widgets/widgets/plots/image/image.py | 2 + .../widgets/plots/motor_map/motor_map.py | 2 + .../plots/multi_waveform/multi_waveform.py | 2 + .../scatter_waveform/scatter_waveform.py | 2 + .../widgets/plots/waveform/waveform.py | 5 +- .../ring_progress_bar/ring_progress_bar.py | 3 + .../services/bec_status_box/bec_status_box.py | 2 +- 16 files changed, 408 insertions(+), 17 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index d12fd2b8a..bf60a3282 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -106,6 +106,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class AutoUpdates(RPCBase): @property @@ -442,6 +454,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class BECProgressBar(RPCBase): """A custom progress bar with smooth transitions. The displayed text can be customized using a template.""" @@ -525,6 +549,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class BECStatusBox(RPCBase): """An autonomous widget to display the status of BEC services.""" @@ -541,6 +577,25 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @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 +1057,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class DeviceBrowser(RPCBase): """DeviceBrowser is a widget that displays all available devices in the current BEC session.""" @@ -1012,6 +1079,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class DeviceComboBox(RPCBase): """Combobox widget for device input with autocomplete for device names.""" @@ -1045,6 +1124,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class DeviceLineEdit(RPCBase): """Line edit widget for device input with autocomplete for device names.""" @@ -1433,6 +1524,18 @@ def minimal_crosshair_precision(self) -> "int": 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 +2081,18 @@ def minimal_crosshair_precision(self) -> "int": 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 +2705,25 @@ def get_lsp_header(self) -> str: 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 +2999,18 @@ def legend_label_size(self) -> "int": 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 +3423,18 @@ def minimal_crosshair_precision(self) -> "int": 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 +3656,18 @@ def set_positioner(self, positioner: "str | Positioner"): 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 +3697,18 @@ def set_positioner_ver(self, positioner: "str | Positioner"): 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 +3729,18 @@ def set_positioner(self, positioner: "str | Positioner"): 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 +3760,25 @@ def set_positioners(self, device_names: "str"): 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 +3918,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class ResumeButton(RPCBase): """A button that continue scan queue.""" @@ -3715,6 +3940,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class Ring(RPCBase): @rpc_call @@ -3996,6 +4233,25 @@ def enable_auto_updates(self, enable: "bool" = True): 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 +4263,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 +4291,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class ScatterCurve(RPCBase): """Scatter curve item for the scatter waveform widget.""" @@ -4327,6 +4601,18 @@ def minimal_crosshair_precision(self) -> "int": 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 +4915,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class TextBox(RPCBase): """A widget that displays text in plain and HTML format""" @@ -4661,6 +4959,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 +5282,6 @@ def minimal_crosshair_precision(self) -> "int": 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 +5523,18 @@ def remove(self): Cleanup the BECConnector """ + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + class WebsiteWidget(RPCBase): """A simple widget to display a website""" @@ -5252,3 +5574,22 @@ def forward(self): """ 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. + """ diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index ed58aeb2a..dccd82ff5 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING import darkdetect +import PySide6QtAds as QtAds import shiboken6 from bec_lib.logger import bec_logger from qtpy.QtCore import QObject @@ -14,6 +15,7 @@ from bec_widgets.utils.colors import set_theme from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout +from bec_widgets.utils.widget_io import WidgetHierarchy if TYPE_CHECKING: # pragma: no cover from bec_widgets.widgets.containers.dock import BECDock @@ -27,7 +29,7 @@ class BECWidget(BECConnector): # The icon name is the name of the icon in the icon theme, typically a name taken # from fonts.google.com/icons. Override this in subclasses to set the icon name. ICON_NAME = "widgets" - USER_ACCESS = ["remove"] + USER_ACCESS = ["remove", "attach", "detach"] # pylint: disable=too-many-arguments def __init__( @@ -124,6 +126,26 @@ def screenshot(self, file_name: str | None = None): 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(): diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py index 78cd5fa21..4a686d8cf 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py @@ -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) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py index 37bfc90b8..7a8edd00e 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py @@ -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) diff --git a/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py b/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py index f3ac8892a..e16c83718 100644 --- a/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py +++ b/bec_widgets/widgets/control/device_control/positioner_group/positioner_group.py @@ -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) diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 043250e3c..27bad0234 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -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 diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index 076005309..eb05cec70 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -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): diff --git a/bec_widgets/widgets/editors/website/website.py b/bec_widgets/widgets/editors/website/website.py index 7839b7891..fa9c8815d 100644 --- a/bec_widgets/widgets/editors/website/website.py +++ b/bec_widgets/widgets/editors/website/website.py @@ -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 diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index 3173806b8..70312e1f9 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -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", diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index 783062728..fe01e8f67 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -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", diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index d01af7304..e9e5f979e 100644 --- a/bec_widgets/widgets/plots/motor_map/motor_map.py +++ b/bec_widgets/widgets/plots/motor_map/motor_map.py @@ -128,6 +128,8 @@ class MotorMap(PlotBase): "y_log.setter", "legend_label_size", "legend_label_size.setter", + "attach", + "detach", "screenshot", # motor_map specific "color", diff --git a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py index 4a891e80c..ee7bdc786 100644 --- a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py +++ b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py @@ -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", diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 9f4adc6f3..a7b81c896 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -84,6 +84,8 @@ class ScatterWaveform(PlotBase): "legend_label_size.setter", "minimal_crosshair_precision", "minimal_crosshair_precision.setter", + "attach", + "detach", "screenshot", # Scatter Waveform Specific RPC Access "main_curve", diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index 1a43713a9..117fb1391 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -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", diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py index e3db63f91..cfb8f27b1 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py @@ -96,6 +96,9 @@ class RingProgressBar(BECWidget, QWidget): "set_diameter", "reset_diameter", "enable_auto_updates", + "attach", + "detach", + "screenshot", ] def __init__( diff --git a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py index cd21e9b6b..ca22a2f68 100644 --- a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py +++ b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py @@ -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) From 25f9b090270655d8c6cfe8bd7e53ea98554ab14c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 13 Aug 2025 11:22:22 +0200 Subject: [PATCH 11/45] refactor(bec_main_window): main app theme renamed to View --- bec_widgets/widgets/containers/main_window/main_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 7edaff7a4..b6e91f300 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -357,7 +357,7 @@ def _setup_menu_bar(self): ######################################## # 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) From e7f9919620f7f28efba8a308512eaec8cc05959c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 5 Aug 2025 15:58:29 +0200 Subject: [PATCH 12/45] feat(advanced_dock_area): added ads based dock area with profiles --- bec_widgets/cli/client.py | 66 ++ .../jupyter_console/jupyter_console_window.py | 16 +- .../containers/advanced_dock_area/__init__.py | 0 .../advanced_dock_area/advanced_dock_area.py | 857 ++++++++++++++++++ .../toolbar_components/__init__.py | 0 .../toolbar_components/workspace_actions.py | 178 ++++ tests/unit_tests/test_advanced_dock_area.py | 806 ++++++++++++++++ 7 files changed, 1915 insertions(+), 8 deletions(-) create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/__init__.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py create mode 100644 tests/unit_tests/test_advanced_dock_area.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index bf60a3282..4450afbd2 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -119,6 +119,72 @@ def detach(self): """ +class AdvancedDockArea(RPCBase): + @rpc_call + def new( + self, + widget: "BECWidget | str", + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + ) -> "BECWidget": + """ + Creates a new widget or reuses an existing one and schedules its dock creation. + + Args: + widget (BECWidget | str): The widget instance or a string specifying the + type of widget to create. + closable (bool): Whether the dock should be closable. Defaults to True. + floatable (bool): Whether the dock should be floatable. Defaults to True. + movable (bool): Whether the dock should be movable. Defaults to True. + start_floating (bool): Whether to start the dock in a floating state. Defaults to False. + + Returns: + widget: 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. + """ + + class AutoUpdates(RPCBase): @property @rpc_call diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 0e9037f69..26682abd0 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -16,6 +16,7 @@ from bec_widgets.utils import BECDispatcher from bec_widgets.utils.widget_io import WidgetHierarchy as wh +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.dock import BECDockArea from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole @@ -44,6 +45,7 @@ def __init__(self, parent=None): "wh": wh, "dock": self.dock, "im": self.im, + "ads": self.ads, # "mi": self.mi, # "mm": self.mm, # "lm": self.lm, @@ -120,14 +122,12 @@ def _init_ui(self): 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) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/__init__.py b/bec_widgets/widgets/containers/advanced_dock_area/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py new file mode 100644 index 000000000..cd25afd86 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -0,0 +1,857 @@ +from __future__ import annotations + +import os +from typing import cast + +import PySide6QtAds as QtAds +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtCore import QSettings, QSize, Qt +from qtpy.QtGui import QAction +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.toolbar_components.workspace_actions import ( + WorkspaceConnection, + workspace_bundle, +) +from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow +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 + +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 + + +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(BECMainWindow): + RPC = True + PLUGIN = False + USER_ACCESS = ["new", "widget_map", "widget_list", "lock_workspace", "attach_all", "delete_all"] + + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + # Setting the dock manager with flags + QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) + QtAds.CDockManager.setConfigFlag( + QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True + ) + QtAds.CDockManager.setConfigFlag( + QtAds.CDockManager.eConfigFlag.HideSingleCentralWidgetTitleBar, True + ) + self.dock_manager = CDockManager(self) + + # Dock manager helper variables + self._locked = False # Lock state of the workspace + + # Toolbar + self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) + self._setup_toolbar() + self._hook_toolbar() + + # Populate and hook the workspace combo + self._refresh_workspace_list() + + # State manager + self.state_manager = WidgetStateManager(self) + + # Insert Mode menu + self._editable = None + self._setup_developer_mode_menu() + + # Notification center re-raise + self.notification_centre.raise_() + self.statusBar().raise_() + + def minimumSizeHint(self): + return QSize(1200, 800) + + 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() + + ################################################################################ + # 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"), + } + + 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) + + # 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) + ) + bda = ToolbarBundle("dock_actions", self.toolbar.components) + bda.add_action("attach_all") + bda.add_action("screenshot") + bda.add_action("dark_mode") + self.toolbar.add_bundle(bda) + + self.toolbar.show_bundles( + [ + "menu_plots", + "menu_devices", + "menu_utils", + "spacer_bundle", + "workspace", + "dock_actions", + ] + ) + self.addToolBar(Qt.TopToolBarArea, self.toolbar) + + # 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") + + self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all) + self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot) + + def _setup_developer_mode_menu(self): + """Add a 'Developer' checkbox to the View menu after theme actions.""" + mb = self.menuBar() + + # Find the View menu (inherited from BECMainWindow) + view_menu = None + for action in mb.actions(): + if action.menu() and action.menu().title() == "View": + view_menu = action.menu() + break + + if view_menu is None: + # If View menu doesn't exist, create it + view_menu = mb.addMenu("View") + + # Add separator after existing theme actions + view_menu.addSeparator() + + # Add Developer mode checkbox + self._developer_mode_action = QAction("Developer", self, checkable=True) + + # Default selection based on current lock state + self._editable = not self.lock_workspace + self._developer_mode_action.setChecked(self._editable) + + # Wire up action + self._developer_mode_action.triggered.connect(self._on_developer_mode_toggled) + + view_menu.addAction(self._developer_mode_action) + + def _on_developer_mode_toggled(self, checked: bool) -> None: + """Handle developer mode checkbox toggle.""" + self._set_editable(checked) + + 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 + if hasattr(self, "_developer_mode_action"): + self._developer_mode_action.setChecked(editable) + + ################################################################################ + # 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, + ) -> BECWidget: + """ + Creates a new widget or reuses an existing one and schedules its dock creation. + + Args: + widget (BECWidget | str): The widget instance or a string specifying the + type of widget to create. + closable (bool): Whether the dock should be closable. Defaults to True. + floatable (bool): Whether the dock should be floatable. Defaults to True. + movable (bool): Whether the dock should be movable. Defaults to True. + start_floating (bool): Whether to start the dock in a floating state. Defaults to False. + + Returns: + widget: The widget instance. + """ + # 1) Instantiate or look up the widget (this schedules the BECConnector naming logic) + 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, + ) + ) + return widget + + def _create_dock_with_name( + self, + widget: BECWidget, + closable: bool = True, + floatable: bool = False, + movable: bool = True, + start_floating: bool = False, + ): + self._make_dock( + widget, + closable=closable, + floatable=floatable, + movable=movable, + area=QtAds.DockWidgetArea.RightDockWidgetArea, + 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"], self.saveState()) + 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) + window_state = settings.value(SETTINGS_KEYS["state"]) + if window_state: + self.restoreState(window_state) + 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) + + ################################################################################ + # Styling + ################################################################################ + + def cleanup(self): + """ + Cleanup the dock area. + """ + self.delete_all() + self.dark_mode_button.close() + self.dark_mode_button.deleteLater() + super().cleanup() + + +if __name__ == "__main__": + import sys + + if sys.platform.startswith("linux"): + os.environ["QT_QPA_PLATFORM"] = "xcb" + app = QApplication(sys.argv) + dispatcher = BECDispatcher(gui_id="ads") + main_window = AdvancedDockArea() + main_window.show() + main_window.resize(800, 600) + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py new file mode 100644 index 000000000..b994b3ca3 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -0,0 +1,178 @@ +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 + + +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.""" + from ..advanced_dock_area import is_profile_readonly, list_profiles + + 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: + """ + Connection class for workspace actions in AdvancedDockArea. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + 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'.") + super().__init__() + 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 + ) + + @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) diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py new file mode 100644 index 000000000..48cc770f3 --- /dev/null +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -0,0 +1,806 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +import os +import tempfile +from unittest import mock +from unittest.mock import MagicMock, patch + +import pytest +from qtpy.QtCore import QSettings, Qt +from qtpy.QtGui import QAction +from qtpy.QtWidgets import QDialog, QMessageBox + +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( + AdvancedDockArea, + DockSettingsDialog, + SaveProfileDialog, + _profile_path, + _profiles_dir, + is_profile_readonly, + list_profiles, + open_settings, + read_manifest, + set_profile_readonly, + write_manifest, +) + +from .client_mocks import mocked_client + + +@pytest.fixture +def advanced_dock_area(qtbot, mocked_client): + """Create an AdvancedDockArea instance for testing.""" + widget = AdvancedDockArea(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def temp_profile_dir(): + """Create a temporary directory for profile testing.""" + with tempfile.TemporaryDirectory() as temp_dir: + with patch.dict(os.environ, {"BECWIDGETS_PROFILE_DIR": temp_dir}): + yield temp_dir + + +class TestAdvancedDockAreaInit: + """Test initialization and basic properties.""" + + def test_init(self, advanced_dock_area): + assert advanced_dock_area is not None + assert isinstance(advanced_dock_area, AdvancedDockArea) + assert hasattr(advanced_dock_area, "dock_manager") + assert hasattr(advanced_dock_area, "toolbar") + assert hasattr(advanced_dock_area, "dark_mode_button") + assert hasattr(advanced_dock_area, "state_manager") + + def test_minimum_size_hint(self, advanced_dock_area): + size_hint = advanced_dock_area.minimumSizeHint() + assert size_hint.width() == 1200 + assert size_hint.height() == 800 + + def test_rpc_and_plugin_flags(self): + assert AdvancedDockArea.RPC is True + assert AdvancedDockArea.PLUGIN is False + + def test_user_access_list(self): + expected_methods = [ + "new", + "widget_map", + "widget_list", + "lock_workspace", + "attach_all", + "delete_all", + ] + for method in expected_methods: + assert method in AdvancedDockArea.USER_ACCESS + + +class TestDockManagement: + """Test dock creation, management, and manipulation.""" + + def test_new_widget_string(self, advanced_dock_area, qtbot): + """Test creating a new widget from string.""" + initial_count = len(advanced_dock_area.dock_list()) + + # Create a widget by string name + widget = advanced_dock_area.new("Waveform") + + # Wait for the dock to be created (since it's async) + qtbot.wait(200) + + # Check that dock was actually created + assert len(advanced_dock_area.dock_list()) == initial_count + 1 + + # Check widget was returned + assert widget is not None + assert hasattr(widget, "name_established") + + def test_new_widget_instance(self, advanced_dock_area): + """Test creating dock with existing widget instance.""" + from bec_widgets.widgets.plots.waveform.waveform import Waveform + + initial_count = len(advanced_dock_area.dock_list()) + + # Create widget instance + widget_instance = Waveform(parent=advanced_dock_area, client=advanced_dock_area.client) + widget_instance.setObjectName("test_widget") + + # Add it to dock area + result = advanced_dock_area.new(widget_instance) + + # Should return the same instance + assert result == widget_instance + + # No new dock created since we passed an instance, not a string + assert len(advanced_dock_area.dock_list()) == initial_count + + def test_dock_map(self, advanced_dock_area, qtbot): + """Test dock_map returns correct mapping.""" + # Initially empty + dock_map = advanced_dock_area.dock_map() + assert isinstance(dock_map, dict) + initial_count = len(dock_map) + + # Create a widget + advanced_dock_area.new("Waveform") + qtbot.wait(200) + + # Check dock map updated + new_dock_map = advanced_dock_area.dock_map() + assert len(new_dock_map) == initial_count + 1 + + def test_dock_list(self, advanced_dock_area, qtbot): + """Test dock_list returns list of docks.""" + dock_list = advanced_dock_area.dock_list() + assert isinstance(dock_list, list) + initial_count = len(dock_list) + + # Create a widget + advanced_dock_area.new("Waveform") + qtbot.wait(200) + + # Check dock list updated + new_dock_list = advanced_dock_area.dock_list() + assert len(new_dock_list) == initial_count + 1 + + def test_widget_map(self, advanced_dock_area, qtbot): + """Test widget_map returns widget mapping.""" + widget_map = advanced_dock_area.widget_map() + assert isinstance(widget_map, dict) + initial_count = len(widget_map) + + # Create a widget + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Check widget map updated + new_widget_map = advanced_dock_area.widget_map() + assert len(new_widget_map) == initial_count + 1 + + def test_widget_list(self, advanced_dock_area, qtbot): + """Test widget_list returns list of widgets.""" + widget_list = advanced_dock_area.widget_list() + assert isinstance(widget_list, list) + initial_count = len(widget_list) + + # Create a widget + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Check widget list updated + new_widget_list = advanced_dock_area.widget_list() + assert len(new_widget_list) == initial_count + 1 + + def test_attach_all(self, advanced_dock_area, qtbot): + """Test attach_all functionality.""" + # Create multiple widgets + advanced_dock_area.new("DarkModeButton", start_floating=True) + advanced_dock_area.new("DarkModeButton", start_floating=True) + + # Wait for docks to be created + qtbot.wait(200) + + # Should have floating widgets + initial_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) + + # Attach all floating docks + advanced_dock_area.attach_all() + + # Wait a bit for the operation to complete + qtbot.wait(200) + + # Should have fewer floating widgets (or none if all were attached) + final_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) + assert final_floating <= initial_floating + + def test_delete_all(self, advanced_dock_area, qtbot): + """Test delete_all functionality.""" + # Create multiple widgets + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + + # Wait for docks to be created + qtbot.wait(200) + + initial_count = len(advanced_dock_area.dock_list()) + assert initial_count >= 2 + + # Delete all + advanced_dock_area.delete_all() + + # Wait for deletion to complete + qtbot.wait(200) + + # Should have no docks + assert len(advanced_dock_area.dock_list()) == 0 + + +class TestWorkspaceLocking: + """Test workspace locking functionality.""" + + def test_lock_workspace_property_getter(self, advanced_dock_area): + """Test lock_workspace property getter.""" + # Initially unlocked + assert advanced_dock_area.lock_workspace is False + + # Set locked state directly + advanced_dock_area._locked = True + assert advanced_dock_area.lock_workspace is True + + def test_lock_workspace_property_setter(self, advanced_dock_area, qtbot): + """Test lock_workspace property setter.""" + # Create a dock first + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Initially unlocked + assert advanced_dock_area.lock_workspace is False + + # Lock workspace + advanced_dock_area.lock_workspace = True + assert advanced_dock_area._locked is True + assert advanced_dock_area.lock_workspace is True + + # Unlock workspace + advanced_dock_area.lock_workspace = False + assert advanced_dock_area._locked is False + assert advanced_dock_area.lock_workspace is False + + +class TestDeveloperMode: + """Test developer mode functionality.""" + + def test_setup_developer_mode_menu(self, advanced_dock_area): + """Test developer mode menu setup.""" + # The menu should be set up during initialization + assert hasattr(advanced_dock_area, "_developer_mode_action") + assert isinstance(advanced_dock_area._developer_mode_action, QAction) + assert advanced_dock_area._developer_mode_action.isCheckable() + + def test_developer_mode_toggle(self, advanced_dock_area): + """Test developer mode toggle functionality.""" + # Check initial state + initial_editable = advanced_dock_area._editable + + # Toggle developer mode + advanced_dock_area._on_developer_mode_toggled(True) + assert advanced_dock_area._editable is True + assert advanced_dock_area.lock_workspace is False + + advanced_dock_area._on_developer_mode_toggled(False) + assert advanced_dock_area._editable is False + assert advanced_dock_area.lock_workspace is True + + def test_set_editable(self, advanced_dock_area): + """Test _set_editable functionality.""" + # Test setting editable to True + advanced_dock_area._set_editable(True) + assert advanced_dock_area.lock_workspace is False + assert advanced_dock_area._editable is True + + # Test setting editable to False + advanced_dock_area._set_editable(False) + assert advanced_dock_area.lock_workspace is True + assert advanced_dock_area._editable is False + + +class TestToolbarFunctionality: + """Test toolbar setup and functionality.""" + + def test_toolbar_setup(self, advanced_dock_area): + """Test toolbar is properly set up.""" + assert hasattr(advanced_dock_area, "toolbar") + assert hasattr(advanced_dock_area, "_ACTION_MAPPINGS") + + # Check that action mappings are properly set + assert "menu_plots" in advanced_dock_area._ACTION_MAPPINGS + assert "menu_devices" in advanced_dock_area._ACTION_MAPPINGS + assert "menu_utils" in advanced_dock_area._ACTION_MAPPINGS + + def test_toolbar_plot_actions(self, advanced_dock_area): + """Test plot toolbar actions trigger widget creation.""" + plot_actions = [ + "waveform", + "scatter_waveform", + "multi_waveform", + "image", + "motor_map", + "heatmap", + ] + + for action_name in plot_actions: + with patch.object(advanced_dock_area, "new") as mock_new: + menu_plots = advanced_dock_area.toolbar.components.get_action("menu_plots") + action = menu_plots.actions[action_name].action + + # Get the expected widget type from the action mappings + widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_plots"][action_name][2] + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_toolbar_device_actions(self, advanced_dock_area): + """Test device toolbar actions trigger widget creation.""" + device_actions = ["scan_control", "positioner_box"] + + for action_name in device_actions: + with patch.object(advanced_dock_area, "new") as mock_new: + menu_devices = advanced_dock_area.toolbar.components.get_action("menu_devices") + action = menu_devices.actions[action_name].action + + # Get the expected widget type from the action mappings + widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_devices"][action_name][2] + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_toolbar_utils_actions(self, advanced_dock_area): + """Test utils toolbar actions trigger widget creation.""" + utils_actions = ["queue", "vs_code", "status", "progress_bar", "sbb_monitor"] + + for action_name in utils_actions: + with patch.object(advanced_dock_area, "new") as mock_new: + menu_utils = advanced_dock_area.toolbar.components.get_action("menu_utils") + action = menu_utils.actions[action_name].action + + # Skip log_panel as it's disabled + if action_name == "log_panel": + assert not action.isEnabled() + continue + + # Get the expected widget type from the action mappings + widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_utils"][action_name][2] + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_attach_all_action(self, advanced_dock_area, qtbot): + """Test attach_all toolbar action.""" + # Create floating docks + advanced_dock_area.new("DarkModeButton", start_floating=True) + advanced_dock_area.new("DarkModeButton", start_floating=True) + + qtbot.wait(200) + + initial_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) + + # Trigger attach all action + action = advanced_dock_area.toolbar.components.get_action("attach_all").action + action.trigger() + + # Wait a bit for the operation + qtbot.wait(200) + + # Should have fewer or same floating widgets + final_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) + assert final_floating <= initial_floating + + def test_screenshot_action(self, advanced_dock_area, tmpdir): + """Test screenshot toolbar action.""" + # Create a test screenshot file path in tmpdir + screenshot_path = tmpdir.join("test_screenshot.png") + + # Mock the QFileDialog.getSaveFileName to return a test filename + with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(screenshot_path), "PNG Files (*.png)") + + # Mock the screenshot.save method + with mock.patch.object(advanced_dock_area, "grab") as mock_grab: + mock_screenshot = mock.MagicMock() + mock_grab.return_value = mock_screenshot + + # Trigger the screenshot action + action = advanced_dock_area.toolbar.components.get_action("screenshot").action + action.trigger() + + # Verify the dialog was called + mock_dialog.assert_called_once() + + # Verify grab was called + mock_grab.assert_called_once() + + # Verify save was called with the filename + mock_screenshot.save.assert_called_once_with(str(screenshot_path)) + + +class TestDockSettingsDialog: + """Test dock settings dialog functionality.""" + + def test_dock_settings_dialog_init(self, advanced_dock_area): + """Test DockSettingsDialog initialization.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create a real widget + mock_widget = DarkModeButton(parent=advanced_dock_area) + dialog = DockSettingsDialog(advanced_dock_area, mock_widget) + + assert dialog.windowTitle() == "Dock Settings" + assert dialog.isModal() + assert hasattr(dialog, "prop_editor") + + def test_open_dock_settings_dialog(self, advanced_dock_area, qtbot): + """Test opening dock settings dialog.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create real widget and dock + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + # Create a real dock + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + # Mock dialog exec to avoid blocking + with patch.object(DockSettingsDialog, "exec") as mock_exec: + mock_exec.return_value = QDialog.Accepted + + # Call the method + advanced_dock_area._open_dock_settings_dialog(dock, widget) + + # Verify dialog was created and exec called + mock_exec.assert_called_once() + + +class TestSaveProfileDialog: + """Test save profile dialog functionality.""" + + def test_save_profile_dialog_init(self, qtbot): + """Test SaveProfileDialog initialization.""" + dialog = SaveProfileDialog(None, "test_profile") + qtbot.addWidget(dialog) + + assert dialog.windowTitle() == "Save Workspace Profile" + assert dialog.isModal() + assert dialog.name_edit.text() == "test_profile" + assert hasattr(dialog, "readonly_checkbox") + + def test_save_profile_dialog_get_values(self, qtbot): + """Test getting values from SaveProfileDialog.""" + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + + dialog.name_edit.setText("my_profile") + dialog.readonly_checkbox.setChecked(True) + + assert dialog.get_profile_name() == "my_profile" + assert dialog.is_readonly() is True + + def test_save_button_enabled_state(self, qtbot): + """Test save button is enabled/disabled based on name input.""" + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + + # Initially should be disabled (empty name) + assert not dialog.save_btn.isEnabled() + + # Should be enabled when name is entered + dialog.name_edit.setText("test") + assert dialog.save_btn.isEnabled() + + # Should be disabled when name is cleared + dialog.name_edit.setText("") + assert not dialog.save_btn.isEnabled() + + +class TestProfileManagement: + """Test profile management functionality.""" + + def test_profiles_dir_creation(self, temp_profile_dir): + """Test that profiles directory is created.""" + profiles_dir = _profiles_dir() + assert os.path.exists(profiles_dir) + assert profiles_dir == temp_profile_dir + + def test_profile_path(self, temp_profile_dir): + """Test profile path generation.""" + path = _profile_path("test_profile") + expected = os.path.join(temp_profile_dir, "test_profile.ini") + assert path == expected + + def test_open_settings(self, temp_profile_dir): + """Test opening settings for a profile.""" + settings = open_settings("test_profile") + assert isinstance(settings, QSettings) + + def test_list_profiles_empty(self, temp_profile_dir): + """Test listing profiles when directory is empty.""" + profiles = list_profiles() + assert profiles == [] + + def test_list_profiles_with_files(self, temp_profile_dir): + """Test listing profiles with existing files.""" + # Create some test profile files + profile_names = ["profile1", "profile2", "profile3"] + for name in profile_names: + settings = open_settings(name) + settings.setValue("test", "value") + settings.sync() + + profiles = list_profiles() + assert sorted(profiles) == sorted(profile_names) + + def test_readonly_profile_operations(self, temp_profile_dir): + """Test read-only profile functionality.""" + profile_name = "readonly_profile" + + # Initially should not be read-only + assert not is_profile_readonly(profile_name) + + # Set as read-only + set_profile_readonly(profile_name, True) + assert is_profile_readonly(profile_name) + + # Unset read-only + set_profile_readonly(profile_name, False) + assert not is_profile_readonly(profile_name) + + def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtbot): + """Test writing and reading dock manifest.""" + settings = open_settings("test_manifest") + + # Create real docks + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + + # Wait for docks to be created + qtbot.wait(1000) + + docks = advanced_dock_area.dock_list() + + # Write manifest + write_manifest(settings, docks) + settings.sync() + + # Read manifest + items = read_manifest(settings) + + assert len(items) >= 3 + for item in items: + assert "object_name" in item + assert "widget_class" in item + assert "closable" in item + assert "floatable" in item + assert "movable" in item + + +class TestWorkspaceProfileOperations: + """Test workspace profile save/load/delete operations.""" + + def test_save_profile_with_name(self, advanced_dock_area, temp_profile_dir, qtbot): + """Test saving profile with provided name.""" + profile_name = "test_save_profile" + + # Create some docks + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Save profile + advanced_dock_area.save_profile(profile_name) + + # Check that profile file was created + profile_path = _profile_path(profile_name) + assert os.path.exists(profile_path) + + def test_save_profile_readonly_conflict(self, advanced_dock_area, temp_profile_dir): + """Test saving profile when read-only profile exists.""" + profile_name = "readonly_profile" + + # Create a read-only profile + set_profile_readonly(profile_name, True) + settings = open_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog" + ) as mock_dialog_class: + mock_dialog = MagicMock() + mock_dialog.exec.return_value = QDialog.Accepted + mock_dialog.get_profile_name.return_value = profile_name + mock_dialog.is_readonly.return_value = False + mock_dialog_class.return_value = mock_dialog + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning" + ) as mock_warning: + mock_warning.return_value = QMessageBox.No + + advanced_dock_area.save_profile() + + mock_warning.assert_called_once() + + def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, qtbot): + """Test loading profile with widget manifest.""" + profile_name = "test_load_profile" + + # Create a profile with manifest + settings = open_settings(profile_name) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", "test_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + initial_count = len(advanced_dock_area.widget_map()) + + # Load profile + advanced_dock_area.load_profile(profile_name) + + # Wait for widget to be created + qtbot.wait(1000) + + # Check widget was created + widget_map = advanced_dock_area.widget_map() + assert "test_widget" in widget_map + + def test_delete_profile_readonly(self, advanced_dock_area, temp_profile_dir): + """Test deleting read-only profile shows warning.""" + profile_name = "readonly_profile" + + # Create read-only profile + set_profile_readonly(profile_name, True) + settings = open_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + + with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: + mock_combo = MagicMock() + mock_combo.currentText.return_value = profile_name + mock_get_action.return_value.widget = mock_combo + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning" + ) as mock_warning: + advanced_dock_area.delete_profile() + + mock_warning.assert_called_once() + # Profile should still exist + assert os.path.exists(_profile_path(profile_name)) + + def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): + """Test successful profile deletion.""" + profile_name = "deletable_profile" + + # Create regular profile + settings = open_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + + with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: + mock_combo = MagicMock() + mock_combo.currentText.return_value = profile_name + mock_get_action.return_value.widget = mock_combo + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.Yes + + with patch.object(advanced_dock_area, "_refresh_workspace_list") as mock_refresh: + advanced_dock_area.delete_profile() + + mock_question.assert_called_once() + mock_refresh.assert_called_once() + # Profile should be deleted + assert not os.path.exists(_profile_path(profile_name)) + + def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): + """Test refreshing workspace list.""" + # Create some profiles + for name in ["profile1", "profile2"]: + settings = open_settings(name) + settings.setValue("test", "value") + settings.sync() + + with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: + mock_combo = MagicMock() + mock_combo.refresh_profiles = MagicMock() + mock_get_action.return_value.widget = mock_combo + + advanced_dock_area._refresh_workspace_list() + + mock_combo.refresh_profiles.assert_called_once() + + +class TestCleanupAndMisc: + """Test cleanup and miscellaneous functionality.""" + + def test_cleanup(self, advanced_dock_area): + """Test cleanup functionality.""" + with patch.object(advanced_dock_area.dark_mode_button, "close") as mock_close: + with patch.object(advanced_dock_area.dark_mode_button, "deleteLater") as mock_delete: + with patch( + "bec_widgets.widgets.containers.main_window.main_window.BECMainWindow.cleanup" + ) as mock_super_cleanup: + advanced_dock_area.cleanup() + + mock_close.assert_called_once() + mock_delete.assert_called_once() + mock_super_cleanup.assert_called_once() + + def test_delete_dock(self, advanced_dock_area, qtbot): + """Test _delete_dock functionality.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create a real widget and dock + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + initial_count = len(advanced_dock_area.dock_list()) + + # Delete the dock + advanced_dock_area._delete_dock(dock) + + # Wait for deletion to complete + qtbot.wait(200) + + # Verify dock was removed + assert len(advanced_dock_area.dock_list()) == initial_count - 1 + + def test_apply_dock_lock(self, advanced_dock_area, qtbot): + """Test _apply_dock_lock functionality.""" + # Create a dock first + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + # Test locking + advanced_dock_area._apply_dock_lock(True) + # No assertion needed - just verify it doesn't crash + + # Test unlocking + advanced_dock_area._apply_dock_lock(False) + # No assertion needed - just verify it doesn't crash + + def test_make_dock(self, advanced_dock_area): + """Test _make_dock functionality.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create a real widget + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + initial_count = len(advanced_dock_area.dock_list()) + + # Create dock + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + # Verify dock was created + assert dock is not None + assert len(advanced_dock_area.dock_list()) == initial_count + 1 + assert dock.widget() == widget + + def test_install_dock_settings_action(self, advanced_dock_area): + """Test _install_dock_settings_action functionality.""" + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( + DarkModeButton, + ) + + # Create real widget and dock + widget = DarkModeButton(parent=advanced_dock_area) + widget.setObjectName("test_widget") + + dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + + # Verify dock has settings action + assert hasattr(dock, "setting_action") + assert dock.setting_action is not None + + # Verify title bar actions were set + title_bar_actions = dock.titleBarActions() + assert len(title_bar_actions) >= 1 From 7884aec8018781b67b98524da82391b1d7880bc5 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 14 Aug 2025 15:06:07 +0200 Subject: [PATCH 13/45] fix(bec_widgets): by default the linux display manager is switched to xcb --- bec_widgets/__init__.py | 8 ++++++++ .../containers/advanced_dock_area/advanced_dock_area.py | 2 -- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bec_widgets/__init__.py b/bec_widgets/__init__.py index 2621e27e0..af9bf5934 100644 --- a/bec_widgets/__init__.py +++ b/bec_widgets/__init__.py @@ -1,4 +1,12 @@ +import os +import sys + 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" + __all__ = ["BECWidget", "SafeSlot", "SafeProperty"] diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index cd25afd86..0f35149ed 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -847,8 +847,6 @@ def cleanup(self): if __name__ == "__main__": import sys - if sys.platform.startswith("linux"): - os.environ["QT_QPA_PLATFORM"] = "xcb" app = QApplication(sys.argv) dispatcher = BECDispatcher(gui_id="ads") main_window = AdvancedDockArea() From 166b56b560488ee96a6feee78419baecce7906ad Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 15 Aug 2025 15:24:15 +0200 Subject: [PATCH 14/45] refactor(advanced_dock_area): ads changed to separate widget --- .../advanced_dock_area/advanced_dock_area.py | 206 +++++++--- .../toolbar_components/workspace_actions.py | 11 +- tests/unit_tests/test_advanced_dock_area.py | 375 ++++++++++++++++-- 3 files changed, 487 insertions(+), 105 deletions(-) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 0f35149ed..539ffd246 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -5,8 +5,7 @@ import PySide6QtAds as QtAds from PySide6QtAds import CDockManager, CDockWidget -from qtpy.QtCore import QSettings, QSize, Qt -from qtpy.QtGui import QAction +from qtpy.QtCore import Property, QSettings, QSize, Signal from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -39,7 +38,7 @@ WorkspaceConnection, workspace_bundle, ) -from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow +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 @@ -200,45 +199,64 @@ def is_readonly(self) -> bool: return self.readonly_checkbox.isChecked() -class AdvancedDockArea(BECMainWindow): +class AdvancedDockArea(BECWidget, QWidget): RPC = True PLUGIN = False USER_ACCESS = ["new", "widget_map", "widget_list", "lock_workspace", "attach_all", "delete_all"] - def __init__(self, parent=None, *args, **kwargs): + # Define a signal for mode changes + mode_changed = Signal(str) + + def __init__(self, parent=None, mode: str = "developer", *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) + # Setting the dock manager with flags QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) QtAds.CDockManager.setConfigFlag( QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True ) - QtAds.CDockManager.setConfigFlag( - QtAds.CDockManager.eConfigFlag.HideSingleCentralWidgetTitleBar, True - ) self.dock_manager = CDockManager(self) # Dock manager helper variables self._locked = False # Lock state of the workspace + # Initialize mode property first (before toolbar setup) + self._mode = "developer" + # 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) - # Insert Mode menu + # Developer mode state self._editable = None - self._setup_developer_mode_menu() + # 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) - # Notification center re-raise - self.notification_centre.raise_() - self.statusBar().raise_() + # Apply the requested mode after everything is set up + self.mode = mode def minimumSizeHint(self): return QSize(1200, 800) @@ -350,6 +368,7 @@ def _setup_toolbar(self): "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, @@ -371,6 +390,27 @@ def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]): _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) @@ -398,12 +438,21 @@ def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]): 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", @@ -414,7 +463,6 @@ def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]): "dock_actions", ] ) - self.addToolBar(Qt.TopToolBarArea, self.toolbar) # Store mappings on self for use in _hook_toolbar self._ACTION_MAPPINGS = { @@ -439,42 +487,26 @@ def _connect_menu(menu_key: str): _connect_menu("menu_devices") _connect_menu("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) - - def _setup_developer_mode_menu(self): - """Add a 'Developer' checkbox to the View menu after theme actions.""" - mb = self.menuBar() - - # Find the View menu (inherited from BECMainWindow) - view_menu = None - for action in mb.actions(): - if action.menu() and action.menu().title() == "View": - view_menu = action.menu() - break - - if view_menu is None: - # If View menu doesn't exist, create it - view_menu = mb.addMenu("View") - - # Add separator after existing theme actions - view_menu.addSeparator() - - # Add Developer mode checkbox - self._developer_mode_action = QAction("Developer", self, checkable=True) - - # Default selection based on current lock state - self._editable = not self.lock_workspace - self._developer_mode_action.setChecked(self._editable) - - # Wire up action - self._developer_mode_action.triggered.connect(self._on_developer_mode_toggled) + # 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)) - view_menu.addAction(self._developer_mode_action) + _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"]) - def _on_developer_mode_toggled(self, checked: bool) -> None: - """Handle developer mode checkbox toggle.""" - self._set_editable(checked) + 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 @@ -504,8 +536,11 @@ def _set_editable(self, editable: bool) -> None: self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) # Keep Developer mode UI in sync - if hasattr(self, "_developer_mode_action"): - self._developer_mode_action.setChecked(editable) + 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 @@ -718,7 +753,9 @@ def save_profile(self, name: str | None = None): # Save the profile settings = open_settings(name) settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry()) - settings.setValue(SETTINGS_KEYS["state"], self.saveState()) + 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) @@ -766,9 +803,8 @@ def load_profile(self, name: str | None = None): geom = settings.value(SETTINGS_KEYS["geom"]) if geom: self.restoreGeometry(geom) - window_state = settings.value(SETTINGS_KEYS["state"]) - if window_state: - self.restoreState(window_state) + # 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) @@ -831,9 +867,60 @@ def _refresh_workspace_list(self): combo.blockSignals(False) ################################################################################ - # Styling + # 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 switch_to_plot_mode(self): + self.mode = "plot" + + def switch_to_device_mode(self): + self.mode = "device" + + def switch_to_utils_mode(self): + self.mode = "utils" + + def switch_to_developer_mode(self): + self.mode = "developer" + + def switch_to_user_mode(self): + self.mode = "user" + def cleanup(self): """ Cleanup the dock area. @@ -841,6 +928,7 @@ def cleanup(self): self.delete_all() self.dark_mode_button.close() self.dark_mode_button.deleteLater() + self.toolbar.cleanup() super().cleanup() @@ -849,7 +937,9 @@ def cleanup(self): app = QApplication(sys.argv) dispatcher = BECDispatcher(gui_id="ads") - main_window = AdvancedDockArea() - main_window.show() - main_window.resize(800, 600) + window = BECMainWindowNoRPC() + ads = AdvancedDockArea(parent=window, mode="developer") + window.setCentralWidget(ads) + window.show() + window.resize(800, 600) sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py index b994b3ca3..616dcc08c 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -7,6 +7,11 @@ 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): @@ -18,7 +23,6 @@ def __init__(self, parent=None): def refresh_profiles(self): """Refresh the profile list with appropriate icons.""" - from ..advanced_dock_area import is_profile_readonly, list_profiles current_text = self.currentText() self.blockSignals(True) @@ -107,18 +111,18 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: return bundle -class WorkspaceConnection: +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'.") - super().__init__() self._connected = False def connect(self): @@ -155,6 +159,7 @@ def disconnect(self): 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): diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 48cc770f3..2207a31e8 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -6,8 +6,7 @@ from unittest.mock import MagicMock, patch import pytest -from qtpy.QtCore import QSettings, Qt -from qtpy.QtGui import QAction +from qtpy.QtCore import QSettings from qtpy.QtWidgets import QDialog, QMessageBox from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( @@ -50,6 +49,7 @@ class TestAdvancedDockAreaInit: def test_init(self, advanced_dock_area): assert advanced_dock_area is not None assert isinstance(advanced_dock_area, AdvancedDockArea) + assert advanced_dock_area.mode == "developer" assert hasattr(advanced_dock_area, "dock_manager") assert hasattr(advanced_dock_area, "toolbar") assert hasattr(advanced_dock_area, "dark_mode_button") @@ -173,28 +173,6 @@ def test_widget_list(self, advanced_dock_area, qtbot): new_widget_list = advanced_dock_area.widget_list() assert len(new_widget_list) == initial_count + 1 - def test_attach_all(self, advanced_dock_area, qtbot): - """Test attach_all functionality.""" - # Create multiple widgets - advanced_dock_area.new("DarkModeButton", start_floating=True) - advanced_dock_area.new("DarkModeButton", start_floating=True) - - # Wait for docks to be created - qtbot.wait(200) - - # Should have floating widgets - initial_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) - - # Attach all floating docks - advanced_dock_area.attach_all() - - # Wait a bit for the operation to complete - qtbot.wait(200) - - # Should have fewer floating widgets (or none if all were attached) - final_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) - assert final_floating <= initial_floating - def test_delete_all(self, advanced_dock_area, qtbot): """Test delete_all functionality.""" # Create multiple widgets @@ -252,13 +230,6 @@ def test_lock_workspace_property_setter(self, advanced_dock_area, qtbot): class TestDeveloperMode: """Test developer mode functionality.""" - def test_setup_developer_mode_menu(self, advanced_dock_area): - """Test developer mode menu setup.""" - # The menu should be set up during initialization - assert hasattr(advanced_dock_area, "_developer_mode_action") - assert isinstance(advanced_dock_area._developer_mode_action, QAction) - assert advanced_dock_area._developer_mode_action.isCheckable() - def test_developer_mode_toggle(self, advanced_dock_area): """Test developer mode toggle functionality.""" # Check initial state @@ -715,19 +686,6 @@ def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): class TestCleanupAndMisc: """Test cleanup and miscellaneous functionality.""" - def test_cleanup(self, advanced_dock_area): - """Test cleanup functionality.""" - with patch.object(advanced_dock_area.dark_mode_button, "close") as mock_close: - with patch.object(advanced_dock_area.dark_mode_button, "deleteLater") as mock_delete: - with patch( - "bec_widgets.widgets.containers.main_window.main_window.BECMainWindow.cleanup" - ) as mock_super_cleanup: - advanced_dock_area.cleanup() - - mock_close.assert_called_once() - mock_delete.assert_called_once() - mock_super_cleanup.assert_called_once() - def test_delete_dock(self, advanced_dock_area, qtbot): """Test _delete_dock functionality.""" from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( @@ -804,3 +762,332 @@ def test_install_dock_settings_action(self, advanced_dock_area): # Verify title bar actions were set title_bar_actions = dock.titleBarActions() assert len(title_bar_actions) >= 1 + + +class TestModeSwitching: + """Test mode switching functionality.""" + + def test_mode_property_setter_valid_modes(self, advanced_dock_area): + """Test setting valid modes.""" + valid_modes = ["plot", "device", "utils", "developer", "user"] + + for mode in valid_modes: + advanced_dock_area.mode = mode + assert advanced_dock_area.mode == mode + + def test_mode_changed_signal_emission(self, advanced_dock_area, qtbot): + """Test that mode_changed signal is emitted when mode changes.""" + # Set up signal spy + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = "plot" + + # Check signal was emitted with correct argument + assert blocker.args == ["plot"] + + def test_switch_to_plot_mode(self, advanced_dock_area): + """Test switch_to_plot_mode method.""" + advanced_dock_area.switch_to_plot_mode() + assert advanced_dock_area.mode == "plot" + + def test_switch_to_device_mode(self, advanced_dock_area): + """Test switch_to_device_mode method.""" + advanced_dock_area.switch_to_device_mode() + assert advanced_dock_area.mode == "device" + + def test_switch_to_utils_mode(self, advanced_dock_area): + """Test switch_to_utils_mode method.""" + advanced_dock_area.switch_to_utils_mode() + assert advanced_dock_area.mode == "utils" + + def test_switch_to_developer_mode(self, advanced_dock_area): + """Test switch_to_developer_mode method.""" + advanced_dock_area.switch_to_developer_mode() + assert advanced_dock_area.mode == "developer" + + def test_switch_to_user_mode(self, advanced_dock_area): + """Test switch_to_user_mode method.""" + advanced_dock_area.switch_to_user_mode() + assert advanced_dock_area.mode == "user" + + +class TestToolbarModeBundles: + """Test toolbar bundle creation and visibility for different modes.""" + + def test_flat_bundles_created(self, advanced_dock_area): + """Test that flat bundles are created during toolbar setup.""" + # Check that flat bundles exist + assert "flat_plots" in advanced_dock_area.toolbar.bundles + assert "flat_devices" in advanced_dock_area.toolbar.bundles + assert "flat_utils" in advanced_dock_area.toolbar.bundles + + def test_plot_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in plot mode.""" + advanced_dock_area.mode = "plot" + + # Should show only flat_plots bundle (and essential bundles in real implementation) + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_plots" in shown_bundles + + # Should not show other flat bundles + assert "flat_devices" not in shown_bundles + assert "flat_utils" not in shown_bundles + + # Should not show menu bundles + assert "menu_plots" not in shown_bundles + assert "menu_devices" not in shown_bundles + assert "menu_utils" not in shown_bundles + + def test_device_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in device mode.""" + advanced_dock_area.mode = "device" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_devices" in shown_bundles + + # Should not show other flat bundles + assert "flat_plots" not in shown_bundles + assert "flat_utils" not in shown_bundles + + def test_utils_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in utils mode.""" + advanced_dock_area.mode = "utils" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_utils" in shown_bundles + + # Should not show other flat bundles + assert "flat_plots" not in shown_bundles + assert "flat_devices" not in shown_bundles + + def test_developer_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in developer mode.""" + advanced_dock_area.mode = "developer" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + + # Should show menu bundles + assert "menu_plots" in shown_bundles + assert "menu_devices" in shown_bundles + assert "menu_utils" in shown_bundles + + # Should show essential bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + def test_user_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in user mode.""" + advanced_dock_area.mode = "user" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + + # Should show only essential bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + # Should not show any widget creation bundles + assert "menu_plots" not in shown_bundles + assert "menu_devices" not in shown_bundles + assert "menu_utils" not in shown_bundles + assert "flat_plots" not in shown_bundles + assert "flat_devices" not in shown_bundles + assert "flat_utils" not in shown_bundles + + +class TestFlatToolbarActions: + """Test flat toolbar actions functionality.""" + + def test_flat_plot_actions_created(self, advanced_dock_area): + """Test that flat plot actions are created.""" + plot_actions = [ + "flat_waveform", + "flat_scatter_waveform", + "flat_multi_waveform", + "flat_image", + "flat_motor_map", + "flat_heatmap", + ] + + for action_name in plot_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_device_actions_created(self, advanced_dock_area): + """Test that flat device actions are created.""" + device_actions = ["flat_scan_control", "flat_positioner_box"] + + for action_name in device_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_utils_actions_created(self, advanced_dock_area): + """Test that flat utils actions are created.""" + utils_actions = [ + "flat_queue", + "flat_vs_code", + "flat_status", + "flat_progress_bar", + "flat_log_panel", + "flat_sbb_monitor", + ] + + for action_name in utils_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_plot_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat plot actions trigger widget creation.""" + plot_action_mapping = { + "flat_waveform": "Waveform", + "flat_scatter_waveform": "ScatterWaveform", + "flat_multi_waveform": "MultiWaveform", + "flat_image": "Image", + "flat_motor_map": "MotorMap", + "flat_heatmap": "Heatmap", + } + + for action_name, widget_type in plot_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_flat_device_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat device actions trigger widget creation.""" + device_action_mapping = { + "flat_scan_control": "ScanControl", + "flat_positioner_box": "PositionerBox", + } + + for action_name, widget_type in device_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat utils actions trigger widget creation.""" + utils_action_mapping = { + "flat_queue": "BECQueue", + "flat_vs_code": "VSCodeEditor", + "flat_status": "BECStatusBox", + "flat_progress_bar": "RingProgressBar", + "flat_sbb_monitor": "SBBMonitor", + } + + for action_name, widget_type in utils_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + + # Skip log_panel as it's disabled + if action_name == "flat_log_panel": + assert not action.isEnabled() + continue + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_flat_log_panel_action_disabled(self, advanced_dock_area): + """Test that flat log panel action is disabled.""" + action = advanced_dock_area.toolbar.components.get_action("flat_log_panel").action + assert not action.isEnabled() + + +class TestModeTransitions: + """Test mode transitions and state consistency.""" + + def test_mode_transition_sequence(self, advanced_dock_area, qtbot): + """Test sequence of mode transitions.""" + modes = ["plot", "device", "utils", "developer", "user"] + + for mode in modes: + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = mode + + assert advanced_dock_area.mode == mode + assert blocker.args == [mode] + + def test_mode_consistency_after_multiple_changes(self, advanced_dock_area): + """Test mode consistency after multiple rapid changes.""" + # Rapidly change modes + advanced_dock_area.mode = "plot" + advanced_dock_area.mode = "device" + advanced_dock_area.mode = "utils" + advanced_dock_area.mode = "developer" + advanced_dock_area.mode = "user" + + # Final state should be consistent + assert advanced_dock_area.mode == "user" + + # Toolbar should show correct bundles for user mode + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + def test_toolbar_refresh_on_mode_change(self, advanced_dock_area): + """Test that toolbar is properly refreshed when mode changes.""" + initial_bundles = set(advanced_dock_area.toolbar.shown_bundles) + + # Change to a different mode + advanced_dock_area.mode = "plot" + plot_bundles = set(advanced_dock_area.toolbar.shown_bundles) + + # Bundles should be different + assert initial_bundles != plot_bundles + assert "flat_plots" in plot_bundles + + def test_mode_switching_preserves_existing_docks(self, advanced_dock_area, qtbot): + """Test that mode switching doesn't affect existing docked widgets.""" + # Create some widgets + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + initial_dock_count = len(advanced_dock_area.dock_list()) + initial_widget_count = len(advanced_dock_area.widget_list()) + + # Switch modes + advanced_dock_area.mode = "plot" + advanced_dock_area.mode = "device" + advanced_dock_area.mode = "user" + + # Dock and widget counts should remain the same + assert len(advanced_dock_area.dock_list()) == initial_dock_count + assert len(advanced_dock_area.widget_list()) == initial_widget_count + + +class TestModeProperty: + """Test mode property getter and setter behavior.""" + + def test_mode_property_getter(self, advanced_dock_area): + """Test mode property getter returns correct value.""" + # Set internal mode directly and test getter + advanced_dock_area._mode = "plot" + assert advanced_dock_area.mode == "plot" + + advanced_dock_area._mode = "device" + assert advanced_dock_area.mode == "device" + + def test_mode_property_setter_updates_internal_state(self, advanced_dock_area): + """Test mode property setter updates internal state.""" + advanced_dock_area.mode = "plot" + assert advanced_dock_area._mode == "plot" + + advanced_dock_area.mode = "utils" + assert advanced_dock_area._mode == "utils" + + def test_mode_property_setter_triggers_toolbar_update(self, advanced_dock_area): + """Test mode property setter triggers toolbar update.""" + with patch.object(advanced_dock_area.toolbar, "show_bundles") as mock_show_bundles: + advanced_dock_area.mode = "plot" + mock_show_bundles.assert_called_once() + + def test_multiple_mode_changes(self, advanced_dock_area, qtbot): + """Test multiple rapid mode changes.""" + modes = ["plot", "device", "utils", "developer", "user"] + + for i, mode in enumerate(modes): + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = mode + + assert advanced_dock_area.mode == mode + assert blocker.args == [mode] From 0756ebb3899847a027fac09233be546fdfc6a275 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 18 Aug 2025 23:03:51 +0200 Subject: [PATCH 15/45] feat(advanced_dock_area): ads has default direction --- bec_widgets/cli/client.py | 33 ++++-- .../advanced_dock_area/advanced_dock_area.py | 109 ++++++++++++------ tests/unit_tests/test_advanced_dock_area.py | 37 +----- 3 files changed, 99 insertions(+), 80 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 4450afbd2..5f7ec6ec7 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -128,20 +128,21 @@ def new( floatable: "bool" = True, movable: "bool" = True, start_floating: "bool" = False, + where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, ) -> "BECWidget": """ - Creates a new widget or reuses an existing one and schedules its dock creation. + Create a new widget (or reuse an instance) and add it as a dock. Args: - widget (BECWidget | str): The widget instance or a string specifying the - type of widget to create. - closable (bool): Whether the dock should be closable. Defaults to True. - floatable (bool): Whether the dock should be floatable. Defaults to True. - movable (bool): Whether the dock should be movable. Defaults to True. - start_floating (bool): Whether to start the dock in a floating state. Defaults to False. - + 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: - widget: The widget instance. + The widget instance. """ @rpc_call @@ -184,6 +185,20 @@ 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 diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 539ffd246..3cb8d739c 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -1,11 +1,11 @@ from __future__ import annotations import os -from typing import cast +from typing import cast, Literal import PySide6QtAds as QtAds from PySide6QtAds import CDockManager, CDockWidget -from qtpy.QtCore import Property, QSettings, QSize, Signal +from qtpy.QtCore import QSettings, Signal from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -202,12 +202,28 @@ def is_readonly(self) -> bool: class AdvancedDockArea(BECWidget, QWidget): RPC = True PLUGIN = False - USER_ACCESS = ["new", "widget_map", "widget_list", "lock_workspace", "attach_all", "delete_all"] + 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", *args, **kwargs): + 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) @@ -219,10 +235,10 @@ def __init__(self, parent=None, mode: str = "developer", *args, **kwargs): self._root_layout.setSpacing(0) # Setting the dock manager with flags - QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) - QtAds.CDockManager.setConfigFlag( - QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True - ) + # QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) + # QtAds.CDockManager.setConfigFlag( + # QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True + # ) self.dock_manager = CDockManager(self) # Dock manager helper variables @@ -230,6 +246,11 @@ def __init__(self, parent=None, mode: str = "developer", *args, **kwargs): # 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) @@ -258,9 +279,6 @@ def __init__(self, parent=None, mode: str = "developer", *args, **kwargs): # Apply the requested mode after everything is set up self.mode = mode - def minimumSizeHint(self): - return QSize(1200, 800) - def _make_dock( self, widget: QWidget, @@ -332,6 +350,19 @@ def _delete_dock(self, dock: CDockWidget) -> None: 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 ################################################################################ @@ -553,22 +584,25 @@ def new( floatable: bool = True, movable: bool = True, start_floating: bool = False, + where: Literal["left", "right", "top", "bottom"] | None = None, ) -> BECWidget: """ - Creates a new widget or reuses an existing one and schedules its dock creation. + Create a new widget (or reuse an instance) and add it as a dock. Args: - widget (BECWidget | str): The widget instance or a string specifying the - type of widget to create. - closable (bool): Whether the dock should be closable. Defaults to True. - floatable (bool): Whether the dock should be floatable. Defaults to True. - movable (bool): Whether the dock should be movable. Defaults to True. - start_floating (bool): Whether to start the dock in a floating state. Defaults to False. - + 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: - widget: The widget instance. + The widget instance. """ - # 1) Instantiate or look up the widget (this schedules the BECConnector naming logic) + 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( @@ -578,8 +612,20 @@ def new( 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( @@ -589,13 +635,15 @@ def _create_dock_with_name( 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=QtAds.DockWidgetArea.RightDockWidgetArea, + area=target_area, start_floating=start_floating, ) self.dock_manager.setFocus() @@ -906,21 +954,6 @@ def mode(self, new_mode: str): # Fallback to user mode self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) - def switch_to_plot_mode(self): - self.mode = "plot" - - def switch_to_device_mode(self): - self.mode = "device" - - def switch_to_utils_mode(self): - self.mode = "utils" - - def switch_to_developer_mode(self): - self.mode = "developer" - - def switch_to_user_mode(self): - self.mode = "user" - def cleanup(self): """ Cleanup the dock area. @@ -938,7 +971,7 @@ def cleanup(self): app = QApplication(sys.argv) dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() - ads = AdvancedDockArea(parent=window, mode="developer") + ads = AdvancedDockArea(mode="developer", root_widget=True) window.setCentralWidget(ads) window.show() window.resize(800, 600) diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 2207a31e8..6f5b25e8a 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -55,11 +55,6 @@ def test_init(self, advanced_dock_area): assert hasattr(advanced_dock_area, "dark_mode_button") assert hasattr(advanced_dock_area, "state_manager") - def test_minimum_size_hint(self, advanced_dock_area): - size_hint = advanced_dock_area.minimumSizeHint() - assert size_hint.width() == 1200 - assert size_hint.height() == 800 - def test_rpc_and_plugin_flags(self): assert AdvancedDockArea.RPC is True assert AdvancedDockArea.PLUGIN is False @@ -97,7 +92,7 @@ def test_new_widget_string(self, advanced_dock_area, qtbot): assert widget is not None assert hasattr(widget, "name_established") - def test_new_widget_instance(self, advanced_dock_area): + def test_new_widget_instance(self, advanced_dock_area, qtbot): """Test creating dock with existing widget instance.""" from bec_widgets.widgets.plots.waveform.waveform import Waveform @@ -113,8 +108,9 @@ def test_new_widget_instance(self, advanced_dock_area): # Should return the same instance assert result == widget_instance - # No new dock created since we passed an instance, not a string - assert len(advanced_dock_area.dock_list()) == initial_count + qtbot.wait(200) + + assert len(advanced_dock_area.dock_list()) == initial_count + 1 def test_dock_map(self, advanced_dock_area, qtbot): """Test dock_map returns correct mapping.""" @@ -784,31 +780,6 @@ def test_mode_changed_signal_emission(self, advanced_dock_area, qtbot): # Check signal was emitted with correct argument assert blocker.args == ["plot"] - def test_switch_to_plot_mode(self, advanced_dock_area): - """Test switch_to_plot_mode method.""" - advanced_dock_area.switch_to_plot_mode() - assert advanced_dock_area.mode == "plot" - - def test_switch_to_device_mode(self, advanced_dock_area): - """Test switch_to_device_mode method.""" - advanced_dock_area.switch_to_device_mode() - assert advanced_dock_area.mode == "device" - - def test_switch_to_utils_mode(self, advanced_dock_area): - """Test switch_to_utils_mode method.""" - advanced_dock_area.switch_to_utils_mode() - assert advanced_dock_area.mode == "utils" - - def test_switch_to_developer_mode(self, advanced_dock_area): - """Test switch_to_developer_mode method.""" - advanced_dock_area.switch_to_developer_mode() - assert advanced_dock_area.mode == "developer" - - def test_switch_to_user_mode(self, advanced_dock_area): - """Test switch_to_user_mode method.""" - advanced_dock_area.switch_to_user_mode() - assert advanced_dock_area.mode == "user" - class TestToolbarModeBundles: """Test toolbar bundle creation and visibility for different modes.""" From 062042c35c158628b502d3c717e4c6783788af6b Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 18 Aug 2025 23:06:11 +0200 Subject: [PATCH 16/45] fix(advanced_dock_area): dock manager global flags initialised in BW init to prevent segfault --- bec_widgets/__init__.py | 8 ++++++++ .../containers/advanced_dock_area/advanced_dock_area.py | 8 ++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/bec_widgets/__init__.py b/bec_widgets/__init__.py index af9bf5934..3d7d19fbd 100644 --- a/bec_widgets/__init__.py +++ b/bec_widgets/__init__.py @@ -1,6 +1,8 @@ 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 @@ -9,4 +11,10 @@ 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"] diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 3cb8d739c..c38fb3a1c 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import cast, Literal +from typing import Literal, cast import PySide6QtAds as QtAds from PySide6QtAds import CDockManager, CDockWidget @@ -234,11 +234,7 @@ def __init__( self._root_layout.setContentsMargins(0, 0, 0, 0) self._root_layout.setSpacing(0) - # Setting the dock manager with flags - # QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) - # QtAds.CDockManager.setConfigFlag( - # QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True - # ) + # Init Dock Manager self.dock_manager = CDockManager(self) # Dock manager helper variables From 022b10ff7a9141ff470f6a8dad0ef51f5af7339d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 19 Aug 2025 11:20:48 +0200 Subject: [PATCH 17/45] refactor(advanced_dock_area): profile tools moved to separate module --- .../advanced_dock_area/advanced_dock_area.py | 95 +++---------------- .../advanced_dock_area/profile_utils.py | 79 +++++++++++++++ tests/unit_tests/test_advanced_dock_area.py | 32 ++----- 3 files changed, 100 insertions(+), 106 deletions(-) create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index c38fb3a1c..2f5f233b4 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -5,7 +5,7 @@ import PySide6QtAds as QtAds from PySide6QtAds import CDockManager, CDockWidget -from qtpy.QtCore import QSettings, Signal +from qtpy.QtCore import Signal from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -34,6 +34,16 @@ 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, @@ -54,81 +64,6 @@ from bec_widgets.widgets.utility.logpanel import LogPanel from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton -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 - class DockSettingsDialog(QDialog): @@ -751,7 +686,7 @@ def save_profile(self, name: str | None = None): 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): + if os.path.exists(profile_path(name)) and is_profile_readonly(name): suggested_name = f"{name}_custom" reply = QMessageBox.warning( self, @@ -771,14 +706,14 @@ def save_profile(self, name: str | None = None): 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): + 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): + if os.path.exists(profile_path(name)) and is_profile_readonly(name): QMessageBox.warning( self, "Read-only Profile", @@ -889,7 +824,7 @@ def delete_profile(self): if reply != QMessageBox.Yes: return - file_path = _profile_path(name) + file_path = profile_path(name) try: os.remove(file_path) except FileNotFoundError: diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py new file mode 100644 index 000000000..47fe1ddd7 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -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 diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 6f5b25e8a..755571234 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -13,11 +13,12 @@ AdvancedDockArea, DockSettingsDialog, SaveProfileDialog, - _profile_path, - _profiles_dir, +) +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( is_profile_readonly, list_profiles, open_settings, + profile_path, read_manifest, set_profile_readonly, write_manifest, @@ -457,15 +458,9 @@ def test_save_button_enabled_state(self, qtbot): class TestProfileManagement: """Test profile management functionality.""" - def test_profiles_dir_creation(self, temp_profile_dir): - """Test that profiles directory is created.""" - profiles_dir = _profiles_dir() - assert os.path.exists(profiles_dir) - assert profiles_dir == temp_profile_dir - def test_profile_path(self, temp_profile_dir): """Test profile path generation.""" - path = _profile_path("test_profile") + path = profile_path("test_profile") expected = os.path.join(temp_profile_dir, "test_profile.ini") assert path == expected @@ -539,21 +534,6 @@ def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtb class TestWorkspaceProfileOperations: """Test workspace profile save/load/delete operations.""" - def test_save_profile_with_name(self, advanced_dock_area, temp_profile_dir, qtbot): - """Test saving profile with provided name.""" - profile_name = "test_save_profile" - - # Create some docks - advanced_dock_area.new("DarkModeButton") - qtbot.wait(200) - - # Save profile - advanced_dock_area.save_profile(profile_name) - - # Check that profile file was created - profile_path = _profile_path(profile_name) - assert os.path.exists(profile_path) - def test_save_profile_readonly_conflict(self, advanced_dock_area, temp_profile_dir): """Test saving profile when read-only profile exists.""" profile_name = "readonly_profile" @@ -632,7 +612,7 @@ def test_delete_profile_readonly(self, advanced_dock_area, temp_profile_dir): mock_warning.assert_called_once() # Profile should still exist - assert os.path.exists(_profile_path(profile_name)) + assert os.path.exists(profile_path(profile_name)) def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): """Test successful profile deletion.""" @@ -659,7 +639,7 @@ def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): mock_question.assert_called_once() mock_refresh.assert_called_once() # Profile should be deleted - assert not os.path.exists(_profile_path(profile_name)) + assert not os.path.exists(profile_path(profile_name)) def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): """Test refreshing workspace list.""" From ab5a78e2fdfdbaeea0f1ee0597a00b3f2b9ebd42 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 21 Aug 2025 18:14:02 +0200 Subject: [PATCH 18/45] build(bec_qthemes): version 1.0 dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7c765887b..ccaa9294b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ dependencies = [ "bec_ipython_client~=3.52", # needed for jupyter console "bec_lib~=3.52", - "bec_qthemes~=0.7, >=0.7", + "bec_qthemes~=1.0", "black~=25.0", # needed for bw-generate-cli "isort~=5.13, >=5.13.2", # needed for bw-generate-cli "pydantic~=2.0", From 6932a5e2ddd218935b7a05ce957f70b0cfc01449 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 19 Aug 2025 10:50:56 +0200 Subject: [PATCH 19/45] fix(bec_widgets): adapt to bec_qthemes 1.0 --- .../jupyter_console/jupyter_console_window.py | 2 + bec_widgets/utils/bec_widget.py | 6 +-- bec_widgets/utils/colors.py | 45 ++++--------------- bec_widgets/utils/toolbars/toolbar.py | 4 +- .../advanced_dock_area/advanced_dock_area.py | 2 + .../widgets/containers/dock/dock_area.py | 4 +- .../containers/main_window/main_window.py | 15 ++++--- .../buttons/stop_button/stop_button.py | 4 +- .../positioner_box/positioner_box.py | 4 +- .../positioner_box_2d/positioner_box_2d.py | 4 +- .../device_combobox/device_combobox.py | 4 +- .../device_line_edit/device_line_edit.py | 4 +- .../signal_combobox/signal_combobox.py | 4 +- .../signal_line_edit/signal_line_edit.py | 4 +- .../control/scan_control/scan_control.py | 13 ++---- .../dap/dap_combo_box/dap_combo_box.py | 4 +- .../widgets/editors/dict_backed_table.py | 4 +- .../editors/scan_metadata/scan_metadata.py | 4 +- bec_widgets/widgets/games/minesweeper.py | 4 +- .../widgets/plots/motor_map/motor_map.py | 4 +- .../scatter_waveform/scatter_waveform.py | 5 ++- .../widgets/plots/waveform/waveform.py | 4 +- .../services/bec_status_box/bec_status_box.py | 4 +- .../services/device_browser/device_browser.py | 4 +- .../device_item/device_config_dialog.py | 4 +- .../device_item/device_signal_display.py | 4 +- .../widgets/utility/logpanel/logpanel.py | 4 +- .../dark_mode_button/dark_mode_button.py | 6 +-- tests/unit_tests/test_dark_mode_button.py | 4 +- 29 files changed, 73 insertions(+), 105 deletions(-) diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 26682abd0..3a693bb42 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -15,6 +15,7 @@ ) 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 @@ -169,6 +170,7 @@ def closeEvent(self, event): 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) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index dccd82ff5..502493e25 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -12,7 +12,7 @@ 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.colors import apply_theme from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.widget_io import WidgetHierarchy @@ -69,9 +69,9 @@ def __init__( # 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") + apply_theme("dark") else: - set_theme("light") + apply_theme("light") if theme_update: logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 9aa40c3ba..781809195 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -3,10 +3,9 @@ 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.QtGui import QColor from qtpy.QtWidgets import QApplication @@ -23,7 +22,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,38 +38,6 @@ 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 apply_theme(theme: Literal["dark", "light"]): """ Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead. @@ -133,8 +103,9 @@ def apply_theme(theme: Literal["dark", "light"]): 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) + apply_theme_global(theme) # TODO for now this is patch + # style = bec_qthemes.load_stylesheet(theme) + # app.setStyleSheet(style) class Colors: diff --git a/bec_widgets/utils/toolbars/toolbar.py b/bec_widgets/utils/toolbars/toolbar.py index 21b3c7107..c1b7b7f28 100644 --- a/bec_widgets/utils/toolbars/toolbar.py +++ b/bec_widgets/utils/toolbars/toolbar.py @@ -10,7 +10,7 @@ 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 @@ def enable_fps_monitor(self, enabled: bool): 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_()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 2f5f233b4..5b3f5a93e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -171,6 +171,7 @@ def __init__( # Init Dock Manager self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") # Dock manager helper variables self._locked = False # Lock state of the workspace @@ -906,4 +907,5 @@ def cleanup(self): window.setCentralWidget(ads) window.show() window.resize(800, 600) + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/dock/dock_area.py b/bec_widgets/widgets/containers/dock/dock_area.py index ca6a698b1..50210b24e 100644 --- a/bec_widgets/widgets/containers/dock/dock_area.py +++ b/bec_widgets/widgets/containers/dock/dock_area.py @@ -616,10 +616,10 @@ def remove(self) -> None: 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") diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index b6e91f300..78719e08f 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -19,7 +19,7 @@ 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 @@ -374,11 +374,12 @@ def _setup_menu_bar(self): 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 @@ def change_theme(self, theme: str): 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: diff --git a/bec_widgets/widgets/control/buttons/stop_button/stop_button.py b/bec_widgets/widgets/control/buttons/stop_button/stop_button.py index 7d0456ecc..fdedf4f1f 100644 --- a/bec_widgets/widgets/control/buttons/stop_button/stop_button.py +++ b/bec_widgets/widgets/control/buttons/stop_button/stop_button.py @@ -31,9 +31,7 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=F 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) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py index 4a686d8cf..a573623e6 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box/positioner_box.py @@ -12,7 +12,7 @@ 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 ( @@ -259,7 +259,7 @@ def on_setpoint_change(self): 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() diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py index 7a8edd00e..fdb5df06d 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_2d/positioner_box_2d.py @@ -13,7 +13,7 @@ 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 ( @@ -478,7 +478,7 @@ def on_setpoint_change_ver(self): from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = PositionerBox2D() widget.show() diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index b80227beb..620773bd8 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -202,10 +202,10 @@ def validate_device(self, device: str) -> bool: # type: ignore[override] # 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() diff --git a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py index 3a0f1925c..e9d523fd8 100644 --- a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +++ b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py @@ -175,13 +175,13 @@ def check_validity(self, input_text: str) -> None: # 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() diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py index 7c0bdaddb..134bc8360 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +++ b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py @@ -179,10 +179,10 @@ def selected_signal_comp_name(self) -> str: # 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() diff --git a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py index 4c8ecb0d9..759706a1a 100644 --- a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py +++ b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py @@ -147,13 +147,13 @@ def on_text_changed(self, text: str): # 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() diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 27bad0234..8948e4ef5 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -20,7 +20,7 @@ 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 @@ -136,13 +136,8 @@ def _init_UI(self): 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 @@ def cleanup(self): # 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() diff --git a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py index c9892ba3c..5931d6726 100644 --- a/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py +++ b/bec_widgets/widgets/dap/dap_combo_box/dap_combo_box.py @@ -175,10 +175,10 @@ def _validate_dap_model(self, model: str | None) -> bool: # 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() diff --git a/bec_widgets/widgets/editors/dict_backed_table.py b/bec_widgets/widgets/editors/dict_backed_table.py index 3efe3887f..a9fb0644f 100644 --- a/bec_widgets/widgets/editors/dict_backed_table.py +++ b/bec_widgets/widgets/editors/dict_backed_table.py @@ -249,10 +249,10 @@ def autoscale(self, autoscale: bool): 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() diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 742936dfd..d3c4be011 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -97,7 +97,7 @@ def set_schema_from_scan(self, scan_name: str | None): 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 @@ class ExampleSchema3(BasicScanMetadata): layout.addWidget(selection) layout.addWidget(scan_metadata) - set_theme("dark") + apply_theme("dark") window = w window.show() app.exec() diff --git a/bec_widgets/widgets/games/minesweeper.py b/bec_widgets/widgets/games/minesweeper.py index 607cde57c..ad9e496f6 100644 --- a/bec_widgets/widgets/games/minesweeper.py +++ b/bec_widgets/widgets/games/minesweeper.py @@ -407,10 +407,10 @@ def cleanup(self): 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() diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index e9e5f979e..dc50ca63e 100644 --- a/bec_widgets/widgets/plots/motor_map/motor_map.py +++ b/bec_widgets/widgets/plots/motor_map/motor_map.py @@ -11,7 +11,7 @@ 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 @@ -830,7 +830,7 @@ def __init__(self): from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = DemoApp() widget.show() widget.resize(1400, 600) diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index a7b81c896..dc99fd31a 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -10,7 +10,6 @@ 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 @@ -546,8 +545,10 @@ def __init__(self): 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) diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index 117fb1391..772278a84 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -25,7 +25,7 @@ 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 @@ -2069,7 +2069,7 @@ def __init__(self): import sys app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = DemoApp() widget.show() widget.resize(1400, 600) diff --git a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py index ca22a2f68..e1b8948de 100644 --- a/bec_widgets/widgets/services/bec_status_box/bec_status_box.py +++ b/bec_widgets/widgets/services/bec_status_box/bec_status_box.py @@ -315,10 +315,10 @@ def cleanup(self): 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()) diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index 55a066684..fbe6fe7d5 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -240,10 +240,10 @@ def cleanup(self): 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_()) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py index f952f2c1a..d81a2beb5 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_dialog.py @@ -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()) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py index 934030a5f..db828c530 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_signal_display.py @@ -110,10 +110,10 @@ def device(self, value: str): 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_()) diff --git a/bec_widgets/widgets/utility/logpanel/logpanel.py b/bec_widgets/widgets/utility/logpanel/logpanel.py index 76aca47d7..ad5dee294 100644 --- a/bec_widgets/widgets/utility/logpanel/logpanel.py +++ b/bec_widgets/widgets/utility/logpanel/logpanel.py @@ -35,7 +35,7 @@ ) 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 @@ def cleanup(self): from qtpy.QtWidgets import QApplication # pylint: disable=ungrouped-imports app = QApplication(sys.argv) - set_theme("dark") + apply_theme("dark") widget = LogPanel() widget.show() diff --git a/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py b/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py index e8f352e8d..6fdb1f15a 100644 --- a/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py +++ b/bec_widgets/widgets/utility/visual/dark_mode_button/dark_mode_button.py @@ -5,7 +5,7 @@ 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 @@ def toggle_dark_mode(self) -> None: """ 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 @@ def update_mode_button(self): if __name__ == "__main__": app = QApplication([]) - set_theme("auto") + apply_theme("dark") w = DarkModeButton() w.show() diff --git a/tests/unit_tests/test_dark_mode_button.py b/tests/unit_tests/test_dark_mode_button.py index 3dca50a20..e8a02bacb 100644 --- a/tests/unit_tests/test_dark_mode_button.py +++ b/tests/unit_tests/test_dark_mode_button.py @@ -4,7 +4,7 @@ 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 From cea2e68fbd4a0d0c88aed3ed773f1806b2d5cead Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 13:12:15 +0200 Subject: [PATCH 20/45] fix:queue abort button fixed --- .../control/buttons/button_abort/button_abort.py | 3 --- bec_widgets/widgets/services/bec_queue/bec_queue.py | 11 +++++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/bec_widgets/widgets/control/buttons/button_abort/button_abort.py b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py index c14bb062e..7adc8d4ca 100644 --- a/bec_widgets/widgets/control/buttons/button_abort/button_abort.py +++ b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py @@ -38,9 +38,6 @@ def __init__( 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) diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index 1530afdaf..768a9c8d6 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -242,8 +242,15 @@ def _create_abort_button(self, scan_id: str) -> AbortButton: 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): From 19e8e5a891afbd6a63b440c3f91c06c247080194 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 13:30:13 +0200 Subject: [PATCH 21/45] fix(toolbar): toolbar menu button fixed --- bec_widgets/utils/toolbars/actions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 4e915cb85..4278877b1 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -446,6 +446,8 @@ def __init__(self, label: str, actions: dict, icon_path: str = None): 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) From 09d00c4f1114f554622715caeb6ac499dbda1239 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 14:35:23 +0200 Subject: [PATCH 22/45] fix: device combobox change paint event to stylesheet change --- .../device_combobox/device_combobox.py | 22 +++---------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index 620773bd8..f6bca8d4b 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -147,24 +147,6 @@ def get_current_device(self) -> object: 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 @@ def check_validity(self, input_text: str) -> None: 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] """ From eb5d56a388818ade97beaee9b3d9bda541f74c37 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 14:35:43 +0200 Subject: [PATCH 23/45] fix: tree items due to pushbutton margins --- .../waveform/settings/curve_settings/curve_tree.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py index c30237e76..763116051 100644 --- a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +++ b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py @@ -3,6 +3,8 @@ import json from typing import TYPE_CHECKING +from qtpy.QtWidgets import QApplication + from bec_lib.logger import bec_logger from bec_qthemes._icon.material_icons import material_icon from qtpy.QtCore import Qt @@ -70,6 +72,7 @@ def __init__( # 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 +118,16 @@ def _init_actions(self): # 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) From e01518898e0d306197d1d05f58954487c77b4c3c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 16:59:11 +0200 Subject: [PATCH 24/45] fix: remove pyqtgraph styling logic --- bec_widgets/utils/bec_widget.py | 4 +- bec_widgets/utils/colors.py | 67 +------------------ bec_widgets/utils/round_frame.py | 65 +++++------------- bec_widgets/widgets/plots/plot_base.py | 4 +- .../settings/curve_settings/curve_tree.py | 3 +- 5 files changed, 25 insertions(+), 118 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 502493e25..bdaefda1a 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -80,8 +80,8 @@ def __init__( 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"): + qapp.theme.theme_changed.connect(self._update_theme) def _update_theme(self, theme: str | None = None): """Update the theme.""" diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 781809195..6f58b10d9 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -40,72 +40,9 @@ def get_accent_colors() -> AccentColors | None: 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 - apply_theme_global(theme) # TODO for now this is patch - # style = bec_qthemes.load_stylesheet(theme) - # app.setStyleSheet(style) + apply_theme_global(theme) class Colors: diff --git a/bec_widgets/utils/round_frame.py b/bec_widgets/utils/round_frame.py index 51ec34979..f8399d1b3 100644 --- a/bec_widgets/utils/round_frame.py +++ b/bec_widgets/utils/round_frame.py @@ -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 @@ def __init__( 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 @@ def __init__( # 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 @@ def update_style(self): """ 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 @@ def __init__(self): 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 diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index 1e112ed7c..f213aa261 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -135,7 +135,7 @@ def __init__( 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) @@ -143,6 +143,8 @@ def apply_theme(self, theme: str): 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") diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py index 763116051..bdff2b546 100644 --- a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +++ b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py @@ -3,12 +3,11 @@ import json from typing import TYPE_CHECKING -from qtpy.QtWidgets import QApplication - 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, From 9bd1efafde800436e4fbfddfae54167e59a58a04 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 17:11:20 +0200 Subject: [PATCH 25/45] fix: compact popup layout spacing --- bec_widgets/utils/compact_popup.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bec_widgets/utils/compact_popup.py b/bec_widgets/utils/compact_popup.py index cb5203b8a..a66e6223d 100644 --- a/bec_widgets/utils/compact_popup.py +++ b/bec_widgets/utils/compact_popup.py @@ -1,6 +1,8 @@ import time from types import SimpleNamespace +from PySide6.QtWidgets import QToolButton + from bec_qthemes import material_icon from qtpy.QtCore import Property, Qt, Signal from qtpy.QtGui import QColor @@ -122,15 +124,14 @@ def __init__(self, parent=None, layout=QVBoxLayout): 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) ) From 391e2f7ef407894bcf8f73f75c4dff1f9942406d Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 25 Aug 2025 18:03:49 +0200 Subject: [PATCH 26/45] chore: fix formatter --- bec_widgets/utils/compact_popup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bec_widgets/utils/compact_popup.py b/bec_widgets/utils/compact_popup.py index a66e6223d..e4335861d 100644 --- a/bec_widgets/utils/compact_popup.py +++ b/bec_widgets/utils/compact_popup.py @@ -1,9 +1,8 @@ import time from types import SimpleNamespace -from PySide6.QtWidgets import QToolButton - from bec_qthemes import material_icon +from PySide6.QtWidgets import QToolButton from qtpy.QtCore import Property, Qt, Signal from qtpy.QtGui import QColor from qtpy.QtWidgets import ( From 652ec81d01bcdd6282ff1ee320eb5319d956528b Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 25 Aug 2025 18:07:13 +0200 Subject: [PATCH 27/45] fix(compact_popup): import from qtpy instead of pyside6 --- bec_widgets/utils/compact_popup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bec_widgets/utils/compact_popup.py b/bec_widgets/utils/compact_popup.py index e4335861d..8d4daef24 100644 --- a/bec_widgets/utils/compact_popup.py +++ b/bec_widgets/utils/compact_popup.py @@ -2,7 +2,6 @@ from types import SimpleNamespace from bec_qthemes import material_icon -from PySide6.QtWidgets import QToolButton from qtpy.QtCore import Property, Qt, Signal from qtpy.QtGui import QColor from qtpy.QtWidgets import ( @@ -12,6 +11,7 @@ QPushButton, QSizePolicy, QSpacerItem, + QToolButton, QVBoxLayout, QWidget, ) From 4889f01ef34a7ce16dd135934054ff0cd0e7aef2 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 25 Aug 2025 19:37:07 +0200 Subject: [PATCH 28/45] build: add missing darkdetect dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ccaa9294b..0a68cd13f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "qtpy~=2.4", "qtmonaco~=0.5", "thefuzz~=0.22", + "darkdetect~=0.8", "PySide6-QtAds==4.4.0", ] From 38a4f3ad9a121eabea42b9ab0b421b04227079e8 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 26 Aug 2025 08:47:55 +0200 Subject: [PATCH 29/45] test: fixes after theme changes --- bec_widgets/utils/bec_widget.py | 2 ++ .../SpinnerWidget/SpinnerWidget_darwin.png | Bin 9490 -> 10025 bytes .../SpinnerWidget/SpinnerWidget_linux.png | Bin 9490 -> 10387 bytes .../SpinnerWidget_started_darwin.png | Bin 14773 -> 14819 bytes tests/unit_tests/test_abort_button.py | 4 ---- tests/unit_tests/test_dark_mode_button.py | 13 ------------- 6 files changed, 2 insertions(+), 17 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index bdaefda1a..6394c6c1f 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -83,6 +83,8 @@ def _connect_to_theme_change(self): if hasattr(qapp, "theme"): qapp.theme.theme_changed.connect(self._update_theme) + @SafeSlot(str) + @SafeSlot() def _update_theme(self, theme: str | None = None): """Update the theme.""" if theme is None: diff --git a/tests/references/SpinnerWidget/SpinnerWidget_darwin.png b/tests/references/SpinnerWidget/SpinnerWidget_darwin.png index 2b75d66a8c62c33426eb1dab52a41e56486d9d5f..54bd8c5e39d495666dbbe18c914e24406b32b221 100644 GIT binary patch literal 10025 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 diff --git a/tests/references/SpinnerWidget/SpinnerWidget_linux.png b/tests/references/SpinnerWidget/SpinnerWidget_linux.png index 2b75d66a8c62c33426eb1dab52a41e56486d9d5f..462241215f9c6e6d6ff92da7046db012c8c9bea5 100644 GIT binary patch literal 10387 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 literal 9490 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 diff --git a/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png b/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png index ff6827cd81d84d075748822840b020dcb8d80c20..85c5a24417cb0f5d767a098fd3595c2e81aee7bf 100644 GIT binary patch literal 14819 zcmW+-Wk6I-7rwi|(kb1b2+|=fA<_yGN;gP1(zT>?cegK%bccX+cO%{1`CUK$F*Enf zoPN%k*{y}GLW~`=np|SYm2Tr9=E#@FK29@>qmFK2F@T z*QC6N46c7;kg#Lm`W&XY{KM|!m#$`PCg!mgv8%)EyYGn{TszM^0InqPQo0N;)hUQu zb1FgGK(q9}!WRop11WC5T2!oyV;A~ zKZ~r&BHsPA9kOZ>jptO9EO%bpe|yD6cLQER`LB{7-?1QJ4-WA+rlD$A!5VkLT8;0^ z=9xw2Uhlv78(d;-SL3bgxC(H)Ws$bD6)o#hj%GG+!mnuH(Z~z?ljEPe#xD)#+-^JE z*8MQ;KXYo>O>!4VVt&bmHRw=c zlqGZb(xSpx9Rjt|S(WW6Z>xElnd#n?`G<#&0vasJ4N8tdA%pr9b}cXK%;twOpq~s9 zX51jlyppx7MnhN@W8-O5Qo1`?cq+WqqEyh~*^*Mw5uZ~uUTjRNr9Q6Q*$Dep_WG-! z%FCIlAV+TdB_6{=9-FJhlOKceS~M-mkV;;?EOEkG%{SrdoBETg1Ze(unGEo4W{* zkJ;RuLsb$EN{EiVa}r94sPbOA<|@vNwzfgubSHHjO^zM?nlfL6{N~KPBtj9#fsmVJ z9w|Aj5V5gw>u6ga{Abe>WJ6+b@pN>fe{}PAZDekjYE@464pa{po zm1Xkrgq$4Zt~|-1Jipq(LTH;hbsEYlgGSZYcQw*NRb3t?+Jd!??v7vAv7`fzd<+)Z z=U8-lr7#lj{MDK@ShVN}%XkP&dFH1U_MN_4 z?yVSZtD&X$o{PbqE%w=`oo|TxP0SX4$Lp9a_{RUaJG6S#AXnKOB;Fq8)51DXUX?;r zz(=&b@ofAkR3rVaW!B~VWfe zO7=**EC!ZbKW;KnB~e*fd1_cnbJ*28%>M?|i?Yqofu zuHt2%n3VKA%HhD&x!=@vwN=7RtlE6)r@^w)<(blY4ffE8#yu=Nd990M7%6s zjoTEPX@ZIeb=dqEaS&+z!CXYjb*r08>+Sfx@xUPs6SLicsmY!G5$ca#$_;Dv!lm4& zy9^ieU#IAk2Ayv~y z;0nE*rh4h&G*e8pd>L~ywONX9g}Jw$!PUWk%)n6}!zQ2*g0gy+8<*53wsaZ8;cUh< z^;L>$BaM4m$hBmLAcrSPdO^KB!gxES2RRODyc_2-obbCh)TMI0d8x*4-(}l)bR~h)Ly>Y3qAh#}|E;o*sH&Bc7vIMA;WxV^;7n6DY z@x94(Qs68QV8yP%G`QN|YfT&?EPKV7m!x#4#n&ffjp1jxF(x9|Cl|gnwKrpxAp-?eZ&E7fd~#dRJfWz8pQ=AT|CHRi~|L?tCiue8_Pj z#gk~dDYelw#`E@jr@bJ=if1Ndv0XWY`b_D z$7Hu1s5shg=fqgvbnmVo9Lv9WD~=EXN{7IrHe}(6Hp8WA4ykj`M~7g4 zS2ym|nEsFGN4#2>GZx1gt#d!jhPoeeAJSa?J=gnui?MxZso{QgUQP9nY-_)GOSUw4 zKT~dM2~DNH3vo#6Mab_ergGWt)z!|p2kZv%x8WT3v z!-U&;W~lGS>`H1u5i-YBc140$j@N04$J5dx8JV`hTG5-U8hRAgL*Og|VCBj0dVxMw z`dK~IX$xznoczG!+Gu+;yLw#o^x@p_nrI}Mjb*5Xp+(omaRTFh@YK>5xD^M8rgFX> zVP~)>mh=e?QYasKJmWhrLNM%6{;Wk#j%0A_JW^lpJ1i<}|1>$hL;Po8G2wz;-b1+0 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 Up{Sh9I{^HXk&qWJ6VvnkA4(fshyVZp literal 14773 zcmX9_bwHHQ(|_(r>5^`wQ@S~&1f)T_8>G7rK~mvMi?oO!NJ!^#2!fP!$ANSs-Mo*# z_x`%uo!OoF?Ck99%wC+9hB7`5H4Xp(_$n_HbN~Pf2LQ-2HWX}Wsa2~50ErM4g=cyJ zIs1!&IeL@17k^RB7q3Ld_TJO{V#Dkrp@2Pu^kTdmDJ>0L@DNMzq$5pzmqqpdcd9;V zk96yz2Ex*B0Vo}r6B7a8ou|1-)HD@#FO=U}CSxak6`Od4wo4}6a z<8(J_iLu7vi#+eJP1^ca3043osCidS0{}2uBP%h$F>=3ugUxAyPf9nakvEZLC~>dh zr{9zH`282PES$Km_dOZYZZAUFR-^zgqPw%Ix}k%KgKsfjOiHO(p_2U_#~<$`zHi>+JH zZY`Sa2aD?0KI8ai#hU($TKw&;ePJ@E%b}By_HK@81_H(dG8beiP+_;~XY~D9)dEMk zXn&cKja`xPVyW!x>(4pWGzNxaDUm_JXw;_#m@?I!G&2U6XX%|cWr#&Z;z2+%22k1# zG?rarOn5brqG|h=+GaNBtdFf;=yC3^osSb(XQMHDRm$fVO0-XkFn|3QO2v)FK>&G2 zGSeo{Z(KxPmBH#VnW8x{R^P)XW$sz0`JC`@^S$_sB#`NJ+JdqmXfkGz4VZinC@Bkz z47^znx)(I6{$Y9?ckSasHL$HNA2hwYbk<^$t3jc|s{Xb1iIWreUHD5Cxm)a&^yP^Z`cbr1w&I7FI{m1H02R?)OVe>fouO z0LmC3?+V_}pDidJ|6pDX&-|Dz`BC^TXnWjrWq-rhZXL;E z#;rC*nJ`^i?DDSOMDo&Ny2Sip+{3@0!~K|JdtOE|ToIU40OS?AJArTt{~}lGf*1&*&wv7##Swr=URc^j0GH1ZK{;j<^7k9KlhkvDP34i zsmg)u@6(>j*V*;#@)N}}LB*$J=h?hbMfFL>PcXw*vsO^&D!=I zteX77OJ-)>R_|oaM{QyPR-=PJh@Q&pPupHmQ;JCG)_64Mb(pq)UkKP`DJrdPCtz64 zd)gy)!a+6B7=n*Z1ciz#O}S;TqsBCO1yJU*OAGs`d8rNWrOU#gTc$MJbfuO$*Ly*c z@aCX9d7Gh)^Nfb~RC^jUt9gNGJJYS-@X@uH&^=B=8`SfH)a2?%LRO3Xcn_wG1n-G1 zJ|`cfX$$VbelB=jIr1J>O?p!dEGTZ=uGRB6kdmR0DlFMkCO>`R$$A<6BWZIDdPI0pfJ38N;e)SbCsIfJ(Cz6ShqD{U7 zDy_UPm7smc-t>VMhriVEmT`OrOhJ*Bg1VJM;LTa$8{z>CAeVYw*~AH1bTYjt_Y zSFisws`_P9WwhH^PhPD|;8m5kmnZwm$gWM21V)U#JXc+0M4!bjWFctMobpRcFz~bY z-zSG_U0b#`y?@t+4|ifLo^J5DxtiNrM*6SE9lTAAVg?v}&^_u+8Wj&hRp@gZTzNxY zoxo+6-jXhClST=pRQsLeSzk_O;~?Ii?UnK8u9*g=?4fE?)X|pZ0fVYym(#^znhp!C z>Zu-K+a0Ul#i?zp$~6!&*}RMno+-lLLU}i>`lX_>PJOdBxh@*#(HEQBkmoSKpa^75 z+N2B*=;b=aI8zB7MZf6h46>AGoqs!5>s2(EQ%)Z^`C9ey%h~ZHAA~>kQ#?Ith^bjd zMP|Nqkv)Gk4&7V`O!MxHG)FCI+_Tk#BKLc-irl4v@1Yt7ZhcxxZI9%GSIY)WxWF2F z+4;Ly(d{19S$8%DJD&3CNoC%joX|Q|a#~8X3Kfat8?m!RenYkC4GVfve2GoR&DIj9 zN?CL+I6#2B?G%E%;jo;ksN#{S&lvKaI4$Mk%qX)kH^LtM(lynxPxF_Sjqhd&alUmH)V{3ndO z`V=;!b%oQ%U|B@S6-Va`3t9GyL3Og0-Hi#BoNSE~C0d2kPi^E?Q*vQF0q2XixHM)Y zUT3y9`6W1+egLa7VDNG3xH}|tcdxsp;;QQ`!NLP^VRsnYsld;*nfv&feVB5Xzn`HC z>vW5>|EL(-;ZqWib{quGdIFz zdkH+lL->Z-wFJ>U-iEX&h8=yr&KEmQ5@4g>348Eyzh!#tM)qItt=(~vL=QK{L4deJ zj`x7kHPSrYm6S*k-D7rNbvxKxtK&AcUI?Xv4O(otM}ye*NPi=)<4{}iHCFA3#m%V5 zSN9J)rXog%skXv+KC}>1CF6md#`O)_=Gfpnd3VoUij(7rNmMn*MB>#PxG^VJkj6F7yohh z^DVvdCny~Z$jn>axiL>+_^q;*>zyY$chzYi-bE#1uV1uqEOmA|9~e*B!gkh?=N7)N z@cm`akAHiM#N;>*;g)gbhe)sO!fcY0?#&f=r-4(ylYVj5A!~)WP|52J9<;ouOlV)Z z<3@25ydp-jj_>^%e&7(g&-o>z?S-PhMh#cOZrn z>j*8I@HCYeAwc(V8nj@WmU|u6#Ao=rIL%bFuled_m9jw%#prj}5`=5RUu2tfuVL5$ z;NGLi7rX-(B}V2dYDUcj?W2Wu6}>9F_-*q0xaF`bm|cqqvcP$=l<9uoSnsEn*W46- zZ@ptu!nN^-$aN_3_D|CBKnOQKcy6Oi52}0VIHG`qf;1MnAmgyJ=gj`gQhu>gtIYlZ z;Y)YK6l&AI&IoC6WeLa~I9aE2Uxz>7sjSJp!i^PyHnGT>vRibZ0{nfeb-!xvWDnO> ziAPv-cPEyt-)EkNOQ$>b&xZP60vCv?z?eM-)mHe9B0!<+61Y`$ZSlbVeSqLxP*+LP z|K$ay@PF;GRg>?pdz9Z(kYi<O3Rqo6ulYDCSbB7V_BK#_-D4A62ie6-35Wvr@_BIJcf?Mobj+Vbx<9|$7GwhC zuE`izi-u3d?zxADvTrU%I+`bB!=A|T1CRv*PGz@Xov=G==A%IQzQ&R{l!W>>X#-xs zb2Gr}M(Frtlf+!3@Q*(GtXMv)$~iXU685qvxH^Vf?5CmN8 z7e4HkpLfe&Nj!f^jJ$H<724b}$$Kf8cX$HE;Pd<*zo{s6YT+Nxz-_;36Gzt?>ght| zH9vbUrtwF|s+$;+)B8Kk^ya^h?TnW{Wc^};AEukG$+im8Y1UmXHjPFkb~!+Je5Y+W z-b0_u16aHkRs4BQyk<0}H7&Q|H_DqjqEH{z{^Ucs%KMwDPKM9KcWp)}pBZ(yArn0G z{w#&_wd4?fIE`uyYlxqE7>D~dgMujt{#>t?O%@|nCT5i&Ty zI80O-b#E%@(Qs0L={}YIn*wu;6DU4i;tKuy(#5XGy-bDk>i5KizviZZK{ph_!(78% zTor~4*x6sMn^9p|ys10_!}c)_#y<_hebe^?0iK>7Dwl&#e=lYR-8^|@r9cbec`tQ( z8ayZ_cYFp_?R&4Qi&|Buef%t{1@qty`W$c14m!JbW2>?&+8 zhHhCVC|$@We2k(rXMOkV)WU?}+etglMdfso`MSpv?I$h-k=%6*dqVuo6VI|@Ju^JzA%LrC&u2+|Gn2x_ z?;2{QBH?w%b9PD7$?zk)WOh0{ZoGy_qc8C;BjvOVY4$r}OtE$D{;NFI}4N zl8#dy-W>K)XnGtpX?dSeetKdKX{$A|@T#JkIsdP2tujGp^BsF|g!A9a-Ro=R6wU{n zQ4rx6#EM_Vs`6uE;v!%Pp_cF8cy%pkaecm521=fH8W{FDVn^^Wv^n5Qy>H2(!^wx? z=7&S)#v9pNBs0z*5~Rt;HEyAIjRx0a{#ub2m-WA^(XOm8%SN&Ccf7+NOY^#%GG6#k zdrK31VkC#OS!L%0mNa*Vcl`L!d0;c$6xy1KfSt`XndM(_3mxr9f65oT!SqMb|HJ;Gg72FW#IcHQ$m@Pl$ufA1=_Xinaru0g?o|<-BsM^O zB_7|af4}oIS8y!wmOAqk!})omWZ)lT|Kk^8H>m?tG$jjel`};DqdvyIysN(keeRU& znYv*9)48kRsvU)+OMgJ0cm3D&p@H}P#yl(lj5a3lMeS%ywUg-}9gV8tSDT%uGX6Q< zC<7VL4kT364jw-GABdxVfA%$*%nZ*ntW%}9HiL;I*aIMWewf22&lvtte!1U>O+nYJ zHXbyQKEX+u%JT@BxZN2*RBf4@obPh*|fA|@H zRzhymDMy^PpYhyCS+IphY-y+?Q$lD z*W4U9aaGf|y>9p1$eqggQf-V*#)JcD3(dUm$7AsrTU+myXv`ZL6e6-y?Q?Y)uuBc` z9Zi^~eAwi(x#8>SEcFv%KuYO)EiHBySr5}~-tIxwuc65$X$!JuSm#*$rdE0WNzJuc zcuM-^`#^(Pv+Wn+CM%CQ$4!IGfIV@j^$)G%aZ}Gg zF2f}zyE@b}t!}N8yZupDh`;Dy&}7T*FbIsJd7eZ$3uS;&8nNZP^y)xfzef@1M z5e>$gwhd&VNj_hwYhfX+Z|*Z|Eu!;n0U#JnS88vzZJQbS+F56F$tz+cVJ9=+}co2%xEa1G(^rYbLhBAU= z6Ep}}2kC8G#w70~?TD^VsLNk%8_&8}-e}V~v%$kfm50r@=6yid6;qT4sr%Df@)gNs zR7+vpC?^js;+jwb9=6RwZ@dvTn{hEMLVPRLDnNy{s=etqwCu?6^Vi2$9jTqz7Wn8# z$7k35SR}K(vv^X->@)mKG%=tKyoq%gv~&^tdi`?D7;%q^Ti@-ojQTc*7T}=Dm*?a< zpH8)NKBFubls;ab*U1m}+t#n5Z|~p!uF2-vxUZ8rSm*b}_(~l%tM3cA@J`Eq&aht; zDoq<~8>DZM+r}-xmcK9nqX2*Jqm=p{-&%1_YOAsxAWy)~y&lm*^EA-Gu!ZWIhad!j7Zhi;X z7WkoE47uGVZ~lW&I5*3Sq;9*ZT+Xln=o~nj4{`i0r&LB0*)vg@hK;OkqD(zyPvN?W zrjp|`)iR_FW+Z~UswD=YvG1Wo811cJjSM7xjSRlJ&V1o(pMD|nHk?q4ev%T|D8uO< zx;Jlgny~$qAJ6s(w*Z}hrZmhmq6)YkN*bKc!z-7Ftt!J^&ZOA2=-6uAx;S3lJ4s{; zg`^E$^_GEN=zU=Lo%J971S32pMSTOneXg)7Xe1M16VoIoDfVpxi2cTpTi#3X_pzdnj}FoRHlaCF`(8? z3=;joc;}Tj(EUSVb#hkuiagRii23hIOZCUdfUog@hQx9G&3iLH`{O|HR@-}J&fS3l zsQyXBZG(ggwh!@{gx-a9`I%hN!JPCIfrl-ptDBv{wc0}4jdi!AN&+Rf9TVNqgie2%{0|p|;y**GLR<<|G| zZen^nE3@_0WtSJ|gQ&a?AG?JLrZ%~V(lpBUJ*VvF82lEzk9LQYMzNvt^IRC(7&N8f zM6_-B*V}=JxGk&=)(w7Ka4``h(5BgB8u~MJSxu_#oNyj+ZwhZRl(QwBsw-GLRL>5| z;BzF6Krc7L(+H>S?oY2jBWzkqepvu3+Nxo)ivxMF_a3=_6Xc?2m=%n~A#H1|`wdqPLXFi6yESmf=zFzUlaX1E5A8R;u;B0DDZ>eU@E0-wEE0Plw#LCoOh;WG7X zJIuqJPv5ntN>o!6R^^ET5QnUR!FC%n@1r<{_`L9sDPX?UFGW_F`ICJG?|wYqxZ@vJ zDeUH=LzYvo$9RXN4nN%foH)9do(0N&9o-)fTb$}EJfKP{Cf{3$V2$dHf({&U7W-W& zJ#O9#D*z>#eu|;xi|{QAIe-sRjY{e6L@PIaBPs?7^G+Jt>YfNy6r~L|{i;}Pu>dAZ zey31TG|!p{nHyXG4wP!cWb)#oK(^{!Z=kL!Z`xm{2qg;TL-fl3#{4QK4ShV?c)PTa zZcX-Xtke{E2&y!0=RE1%pO;64uu%{|OPw2Y*7xUSQk%-D-=o@yti6diALA^8somV+ ztBsEjcX-*jSP;Etdb7!n+?Dd4>!0}~yA4qj)Cy{Ep_fza`hNS9R%i>jZ2utuXXI(z z4lb5m1IFP?PCh4?iXJl@Wn7sb&?RYx7u z;~}c*+5-a))3^q<&?Y`6xl5cRUO2V) zu7S^aULOWS_QMIL8I@#l8)RXEr}6iBZm{p9$~iK+<~|G13PPh<%T5iQG;EZ05)p}+ zq*Ztph<2Gd+}Bc(HM?;ayhWqIxyyhJtRGkC+IHk+X&;uDyr&As`7Ti!Xot6aX0o2w zj957wa+q7(>A%2&!l-=9Q?u_(QVs$;uV0%O4m}&FMh96!=I{)UH%MU1(+8z&WqjjyoY^4Vj5KbE?&8#rBEdQW zQ)07NY574>-ZQWz9EidQapWXtj00w;@qlLj*Aa}W*k~PDKDFsBxwcfQdoEV7+@2!} znC+s^$C8?8O7Hs~MoL8vI{(&5>3sx1oc5M2GK>s!1-xzLdz?r5| z@EhI4g&UpNp=Z3D>cF962G-_PbjNhw;F1OK-D983!v6wk(acV{uk#TIv(0C+ zaJWe)_hyA3ZYXZ`nRn_XXi+DUJ3!!S?WNK)! zVMpMok7Q4U6szRd>88>Sh0KHAL>r@ZPHuMbap`KQuZIPZ#Pk#TNh7TgjC zVV&UmUO&Czrg=}? zEPwDcEZoEian6*>WIR3#5bQ~Ic)Xb}&x@k~zK@ritIJIp5QGk&(Dp}6`RrU7E|yvt zre(Q_;x~Hs^Qnp^(8^V->fKoxKa^mhXQIpN{P8^>$(X-1GDyU_L;-GcK~ zK{>jyj{tnyHF=Z5E{{vT?=7k71T#=-H_jegMYb!GdXxO00T$Zj=ydmx@%9JlvF+(r zVSFh3<5Wjujxe)s{CdFg7B>$XOHv|wb;D!I5RB2%9b-{1%}gmFNrHT6 z?e@PsxtBTZi}`+w3j~M!^{BTPez{ipTKK*t3SCJ{85%IfjX?R{e^2}w$PZL!+Wop( z4ON!MYEg$t5+T7-hk3%_9b3z6ausBL+}d4n|8aZ1WKr1v1|? zby{vvT3$s>L#Aee7YctNzK-38puMXn92{cwb94#=^dEl&olcPjtQ3z znk-+HJ8aEf*~S3%FZuLs!)&M10LXNNlyK?P7H3~miCsrW+R`}tDo4;i3m{Wo9HU$OTO~x?A<8q zIWmrP9$NzP?lKO$`dD(^e2d-j0fm#K z=qMo0sDI@K$1~QhIGrASl(3{LesdrhyTJi zgH|lZi4@?N^X$?;KSLgV6BKiEkS29;ML$|lObLZ_UCJ+1_8o(}kNGP?qla*aXT^z^ zUl5nh9i+fbfYSVD2w=hqaC{2+1;ECN&>I=vhg}q0*iJ7i8x0tJy!HH{ru+0mRO0t3 z=p}>8>6b5q@$L^xOVwyb{}?6f@^?^Dy7Bd2RoXj5yEhnZD=?0B8Lt1Fe;5t~T^T%4 zN41CHPx;%hyDreH9@Ud0PcmLq?75>keE4m0Odl*K)qv~gYf(-Vhg0(G67qU;YKX$p`|45aCQ}9i@P0{w1 zMv6nJ0o5XehSJ$^j>>jWjfaHpJI^#Z_~uOlwC5>k&Bdf!dYj?hWHz+xgv%J*A<8g@ zAp%Q)Be&?zt^!xqTFirX{h^Zos{2AxG{}SXBNTZ0bi>kuq16^2LaQ8z*vA_#-p6)Z0-G$1WSN3bp5o&36e z^4sZ8jXc2oIPS2E_%4tKDCkx?J@Ktjn~s_!iel3Q_|KEz%BP!nJOIac*mTPWyXDJ1 zDd@T8vx_P{6M#_^YPOpq!gKahLjW$rt2TWtE3s36BZ>w5S;0jMiEH(#Soyv{A=C-n z2So1G2qh_Yn70_n9NGa|*6lm%$h<70tz6EtzYb44+R)JgMx9YHUH=T`nDT(%6W^yJ zw#^d2QY}!{f#L(}#HC%v&zXD%(b2@U;*=i=18FLjQMCc@jkdUnRXPFlh9wK=V$~a0 z2wa?Mg+I#to(ev9odV8_DD+RaDQdwq@TMxBkl+%!Xq zY~Y^vcR=iZVUlS*{@)l|TuQI}TU|4~#A5{L&(S?BCky@CU#;;2Y59e396ZsvyomIJ zp-rBxhqdil-~prhC`Y;}xvTn*l_qfb@(O`k+_^kQgS|y>Supc6#DAu^|7dyf{?I@7 z@pD~(F)!uIVb|i<|K=k4=SY!>t2f^i!G$UYiD5DCO2MWfkaXD>573g0Q0cW7`|MXx z^WhC9{6AK86t#yC%W}X>E1|dmw1MLX6po{E-7C!*|G%kAZiVQ{bw>a#&Nvt#gh5Am zrHp*}3=Q@Zz2EKimx9#@W*z|B$P(wH$nu97DGWMOG=f4-fJhUZg$zPV$m@bc6y=jX zimwI=p7Vq+y7aH`+#Al&AR7+nhslHiy_X(1bN1L|xWUi>)B)nq|G_-u22${x;Ml)8 zf`Cf9o*h0c-A^-7y@}KR41cNayIv=V!AqK5`wOH4{m-|^?*)EhCE!bwy&l)!IgO;u zUe}JTn*Q9$FQOJjfmn?B1~J#+QzX6&&%h_FL_|B}^*pDP_oXUxG9zg4f;wN*Qhm|n z1(U0{B5W*&$?9|vpQI{`A^^Atx}>ebWFElg4k=I_Vd{+{_Co`JSQaQH@SNQ6%mzo2 z%@7R}U_^fhhXB|R2p#~#KGuAO2K;y)LreM%f(HAw<+aNBKqjn-2I%4GkNoWh)s1=w zB$f5-K<1R)mRSbD2U_lm_wZRXUuZ{dJXY3!zQW!%TPk_=W z7#7V|7;#`TizT=uToIgGA6$yC2Tr7SKue?L_z~YMjdK6@eW58D5{OvuTouO#rxY2w z_1;^@T}JW<|1)amP8Z73h{Swp$hXoiC|nRX111Db%mPp7&bMC8XeAk?}>hkk^z5Vvl7zd|-qs zLL*(CWA*DYna{bP01GHU;U00dPCU-H0Itiq$E9!o83F4`iyI?c{x#lnJpnkHyRESh zHYS_|+ouFC+@}2QfBLC1j^ye<=}h+NAhmcPM<;L83otmM6;F)B!-Q+#Ec|t*#B@P^ zpPb7!@Syqto>0G6NS@R=ioowQqt!szH_;_^QUNgfL0m-4OU%Tf1G!|_%HtR!}RbFB8dc%il(5w_d*w*BSxmQQpMSU zElNk|syq3kzX5w862J$O=D+{S%^^W6Yp6?I6*qkrYr0<%N%&gH8U%KqY)b%b zk<+>y-1Z+|oyY89V#3dVwfC@%{C{lSTswmI22!A2xhweat}R|X{;iQCz(Ia8%<(xE zAb6RL-VeScg*2zW9h56l1Z%Ou%vhjcj*15dEF1ZwpPI&zL(6AKbOC^j49}7eP|9FN zn>kj`LyyM=0N&$4Ig>#-mZB!Wo}3z(>$AD1s^AKejRL1|B#gOos4Kht|DHdA^e4lP zW}_g8HQ$!QK9AL70iPHhZmNFX3PB|WSxF2r!41pEIP%o%j@Zjr0A6dcE}lUG)U4CX zsQ@enSP4*)NA`sf#*Um7{&j<2MUhEwovx>ZIDSImbqgxb22`UaDfMazFg+0XouIyq zGgLZZZ=XxbJ|)4noc}lU3{}l_!gKH%8)3rB334HWOqbbw8BKcg@rw19t<_Y&1Kp+t zG<7_Q`3lc+gG6vmUvCG}7aIcB3CG-I#k$}cEPVOr&lnrL2vL@o39o$oJefUsie2Zy z`MOhG=Vuv=Y46{?@)d=vkK4+T0OE=Kq24!2j0NcHfqTkOIBdH2h{BcY8GzN0RZD`$ z=tkRXbwM1j|55o#){*`-F%qz59oc3jebiqy@?{L&WBJqeLUt*HvCMS(Oq%~j2sAFo zm=yJtCzR-DvBfZdiY>f|=c?q$i1^3_gXchO-ohg}kX3^f*Amp|yA3GnvDfdswF8zq z1J2!3FSHNO1mM^w()?oZE@wPv4Nf$155eNK99%R&J0vuHd^>%bfKkA%~-cjZxv(GpMo>Qp>uZvpIAoXSW`hl z&-F!7HzP!eu8TS*TB&w}t4oE6te&b7`Oo34rkeculweJdb5cA&gvtLFImP0mGYcv$ zO#=#E&oSb;PTz`4R!+j6$(Gu;M@ZNEi0m&KOk|*f3E#&Bh6ScJn+qW@_0_p&Y4UIL z3X#@vn}K{LDZm6&%wd$o-?U&1z^|aL%(nSAS+STzk`=zS;TnljSlVobxnJy!;*dseIhv0ceXjV-ELZvanFm*2qLGd^ z_vWI(SS1p>ig8On2%yafyhA3Jf#WjrX$Eo%{U?cQVhGprg0o=tY_XZlh zo~G0IBsgDhCaVTCJp_Z#`B6zjZHKm$+YEYWdc^#Po6nb>SD9LqjVsX-YM zIzW|G%O$0i6OE&M2WPphHD!hhuVBq9>COs8%=N7WYN+sX{N)-CrndqsoWzTkUEpdE z!J=nAiBY{apWaQJ4ygV-Xv>xNIDr?flt&B9Ot5c^|A$UH_@pEwz4|uz_;vOh9)NB| zTw@vOFi z%*l9Uwi1cpIb(!fz&Njg(J#t7p!x(fB7LyFni1mgvsgz|aTGZAqU~)6y+dO+FD#qT z0ZMk+pe7sMo0$M(qs|j|ENE0|28dajj#-P(qg!kdF|pdGk6^gNCw_-Zxp!G@W>`d( z)VP+!+NzV2lNhpnJx>6qz@$V%T7E*k$T?g~&g!RMo0}!Rodc!xuO**Xl!EVRYQ7;= zMz`_oY4N&WU4JQpX}=Jpk{%^5C8?)IrW}5D6vaaGw4d*6CFdEa;pxxQE_N1&D?Z7> zb0g4j5LmTjd^NU`LHA%kHrw33sgBUrq*IUn!oIUjZ72`z(eQhuvu<9+Nye;LIiv$kdq-6DZxV>D=@FI{>A-3 z0s`}m*8oa-J0-C6Q#xV75{Y!;I}*8wccI$cpGc89%XJchl(F>l8pS$@{N1*yW$?9y z9-y}3DX`lKvz_tt*i*ayM6SpSFGeyGi5S6z*Y1pd^FknH7Qzg(ZFc`PxOyvLX^rcA zwVc!sT8TSV^?mHzE-%gl& z{MSM3@RZD76sTu)zt-`6YfSMM$bhEmfuR8bW|_qj4W^2PLgd^cZRU5&B?cR}9n7*< zOKOO5pHQ_#Z6;3y|rUi&|~zZ>m4-~>cxm+ zZl4uvWMet(CnkbCEqn7@zqBW1%(38y^e|}ed7F0ODUp>#uHVCIPUzFqHSGFp;PEPl z1HiEybAck{mh75%Pr9c%kBIz@wl)N-t#qLBr~(I%Y1{kayywhbX|9A zak}Q40`s$L=3@?5qS;m%70yR#7Ltjpf%IxWV14TqNTOtgAF59Rb`^O9pyw*6aiT4< zG@UqwH)=nVBs&NizPxUM^^x{J@;@;t9#4jHrm;HLg+@#%_KLjM3Q3 zw_a6pqz9G|N%qis?SpyVpX9OZ&?d%d&yU`}dsM5)h14*~%9rcB1)XXTk%r80lT?2d z!_qPIcx}f_NL6jD1hfhNE!)tR6#Sgylzc$L0uH!kw%M`#u%d&NAJf+`r3pisp975+ zg%p8hViBfq{YV9T(d!uD2_yYFNufz#FDG*7WwA2%Dg3bZ#Qj@Te{L#v*vd(>710KEaqDF%`bRc7t*562aTgFl zn{;vLIQYq4u!!QMOX|8~)FlE3-(HcobNr`N*Iel`@3$1eA2SKKGQ-u1M!w(@t5Ccc zCuLHto7yg7^$w4_&?v~oUeN~(Kqn*qkFP;>U24+EFc1>|49NTd9*VB5mDx9H z(N_Q9?AB^Eo%W?I8Cg}|XVM~rHk}&JCNX_`Unp9yv&+cr+xOY(qx2+98++Mn)VM_F zYnu&5v7Tf!H;StO4QIcJl%NqR83FKBJ!gM`i)HwUoUEnWV8#yJ2YkluXY4ZVdra=| z2y8d5uUX(`j*GH0`t}*feGVAB*j01v7ARBR9aSSn)8az+P^_;p`*&E>@gzvdL=~TH z*+uee8KZk(8ygvrT2op%$^?Bi0Qw^Ug7>R44f}N@T`yIOpwh$MpzMEQJsL2zMA1Dy z7)uQE57mHPbK6`Y4vkYcLj9hUMiq1+?a;leI;Zfk%A_m{WzsOW+tu%4enG<+- z3UB&>FQ4`~rtNJ%kgl^`Pxy(V6o59h)FDh%&eOUKzw~XPE52}7axdP1%~+u8fsdEp z&za_svOOA=b>;KGF2;ntz+T>&%%ijqql!4wCHxzLub%Wa6U5xM4ex6A0 z2kz%vX$awU)x7*Ig~l8SBo+}o`EIM)h*yl4L6flFQaP2k8??_^(yw#?)m8+0jR!U| ztaYE&Oud^m&oQ_@Y*^1%ygO@1`+DM!t3V0CBTn{t9*}3US1qp2nf@D3p3|<|CuFas z@?2W8Rzs{%oAm^Bz z_uMf{gTqw{q9+8UtZB&O7pq{r7sEoGL(WdL71nj@V9ePQIwak9R(_}!LIzA9|x-ujIE zQvBQ=DyD!d3tXFe`1D4{Tv z(v>!S%DX!rT?j^!qxg(G=mK1yi)XlwuG+uy%$+Fo<$0Cv4QKLx9+#LKD{EJ@{Ddd3 z07flZuh0daXYdh{6LYq?&DCJ$m>3t2Jt*6SvgLj>PSXiGWJtd2?OSS$NO@=kLv> Date: Tue, 26 Aug 2025 08:53:11 +0200 Subject: [PATCH 30/45] ci: add artifact upload --- .github/workflows/pytest.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f6f5a84d6..64f9c0c71 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -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: From 7421166bee1f02c1e2babd9b96bf0caf0fd3b3a6 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 26 Aug 2025 09:04:01 +0200 Subject: [PATCH 31/45] fix(serializer): remove deprecated serializer --- bec_widgets/utils/serialization.py | 62 ++++++++++++----------------- tests/unit_tests/test_serializer.py | 5 +-- 2 files changed, 28 insertions(+), 39 deletions(-) diff --git a/bec_widgets/utils/serialization.py b/bec_widgets/utils/serialization.py index fba7be8a0..c017ed327 100644 --- a/bec_widgets/utils/serialization.py +++ b/bec_widgets/utils/serialization.py @@ -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) - - -def module_is_registered(module_name: str) -> bool: - """ - Check if the module is registered in the encoder. - - Args: - module_name (str): The name of the module to check. - - 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 + if not msgpack.is_registered(QPointF): + msgpack.register_codec(QPointFEncoder) + + +class QPointFEncoder(BECCodec): + obj_type: Type = QPointF + + @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 + + @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 diff --git a/tests/unit_tests/test_serializer.py b/tests/unit_tests/test_serializer.py index eadfd940f..0acfb07aa 100644 --- a/tests/unit_tests/test_serializer.py +++ b/tests/unit_tests/test_serializer.py @@ -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) From 0dce0a0f4fad56feb27d10333f5de00931bcb8cd Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 26 Aug 2025 10:26:54 +0200 Subject: [PATCH 32/45] test: fix tests for qtheme v1 --- pyproject.toml | 10 +++++----- .../SpinnerWidget_started_linux.png | Bin 15265 -> 15755 bytes tests/unit_tests/test_dark_mode_button.py | 8 ++++---- tests/unit_tests/test_stop_button.py | 4 ---- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0a68cd13f..e8a562c69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,15 +13,15 @@ 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~=1.0", - "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.1", + "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", "thefuzz~=0.22", diff --git a/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png b/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png index bf2d9470f2e1a5b6b04536b76a0af27833247dfc..662bd4f75528ce7342282937a6d847a1ec99792e 100644 GIT binary patch literal 15755 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 z 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 diff --git a/tests/unit_tests/test_dark_mode_button.py b/tests/unit_tests/test_dark_mode_button.py index 461116291..1491ebc65 100644 --- a/tests/unit_tests/test_dark_mode_button.py +++ b/tests/unit_tests/test_dark_mode_button.py @@ -64,10 +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") + mocked_apply_theme.assert_called_with("light") diff --git a/tests/unit_tests/test_stop_button.py b/tests/unit_tests/test_stop_button.py index b5ecdc1f9..e428a7dec 100644 --- a/tests/unit_tests/test_stop_button.py +++ b/tests/unit_tests/test_stop_button.py @@ -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() From a84459acbfdbddbdce156c526dcfeed3fc84a35d Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 26 Aug 2025 10:37:56 +0200 Subject: [PATCH 33/45] fix(BECWidget): ensure that theme changes are only triggered from alive Qt objects --- bec_widgets/utils/bec_widget.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 6394c6c1f..ee52ae295 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -7,7 +7,7 @@ import PySide6QtAds as QtAds import shiboken6 from bec_lib.logger import bec_logger -from qtpy.QtCore import QObject +from qtpy.QtCore import QObject, QTimer from qtpy.QtWidgets import QApplication, QFileDialog, QWidget from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -47,8 +47,7 @@ def __init__( >>> 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: @@ -83,8 +82,8 @@ def _connect_to_theme_change(self): if hasattr(qapp, "theme"): qapp.theme.theme_changed.connect(self._update_theme) - @SafeSlot(str) - @SafeSlot() + @SafeSlot(str, verify_sender=True) + @SafeSlot(verify_sender=True) def _update_theme(self, theme: str | None = None): """Update the theme.""" if theme is None: From 9c40e31ae59e7b5cb78c3493ff6d154f40b09d6f Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 28 Aug 2025 11:16:36 +0200 Subject: [PATCH 34/45] fix(themes): move apply theme from BECWidget class to server init --- bec_widgets/cli/server.py | 7 +++++++ bec_widgets/utils/bec_widget.py | 12 +----------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/bec_widgets/cli/server.py b/bec_widgets/cli/server.py index ea45c61e1..d27a74f77 100644 --- a/bec_widgets/cli/server.py +++ b/bec_widgets/cli/server.py @@ -7,8 +7,10 @@ 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 @@ def _run(self): 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() diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index ee52ae295..67a4c5e9e 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -3,16 +3,14 @@ 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, QTimer +from qtpy.QtCore import QObject from qtpy.QtWidgets import QApplication, QFileDialog, QWidget from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig -from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.widget_io import WidgetHierarchy @@ -63,14 +61,6 @@ def __init__( ) 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(): - apply_theme("dark") - else: - apply_theme("light") if theme_update: logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") From ab787fc4a8882835c0597a3b6e53f93b2c88403a Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 28 Aug 2025 11:17:06 +0200 Subject: [PATCH 35/45] refactor(spinner): improve enum access --- bec_widgets/widgets/utility/spinner/spinner.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bec_widgets/widgets/utility/spinner/spinner.py b/bec_widgets/widgets/utility/spinner/spinner.py index 099804af7..ab5dadd15 100644 --- a/bec_widgets/widgets/utility/spinner/spinner.py +++ b/bec_widgets/widgets/utility/spinner/spinner.py @@ -49,7 +49,7 @@ def rotate(self): 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 @@ def paintEvent(self, event): 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) From 353c82c8681e85dac1fb7db67abc19fab83eefb3 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 28 Aug 2025 12:47:14 +0200 Subject: [PATCH 36/45] test: apply theme on qapp creation --- tests/unit_tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index aec6e6864..194bfed74 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -5,6 +5,7 @@ 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.QtWidgets import QApplication @@ -24,6 +25,11 @@ def pytest_runtest_makereport(item, call): @pytest.fixture(autouse=True) def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument + qapp = QApplication.instance() + if not hasattr(qapp, "theme"): + apply_theme("light") + qapp.processEvents() + yield # if the test failed, we don't want to check for open widgets as From d69220c6dda4741567c38dd46df341642b437ca2 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 28 Aug 2025 12:53:06 +0200 Subject: [PATCH 37/45] refactor: move to qthemes 1.1.2 --- pyproject.toml | 2 +- tests/unit_tests/conftest.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e8a562c69..fdae48c0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ dependencies = [ "bec_ipython_client~=3.52", # needed for jupyter console "bec_lib~=3.52", - "bec_qthemes~=1.0, >=1.1.1", + "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", diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 194bfed74..b1e858c4a 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -26,8 +26,7 @@ def pytest_runtest_makereport(item, call): @pytest.fixture(autouse=True) def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument qapp = QApplication.instance() - if not hasattr(qapp, "theme"): - apply_theme("light") + apply_theme("light") qapp.processEvents() yield @@ -41,7 +40,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) From a3dc5091e3d70fdb1a3e44b249ca253f558959d2 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sat, 30 Aug 2025 08:54:03 +0200 Subject: [PATCH 38/45] fix: process all deletion events before applying a new theme. Note: this can be dropped once qthemes is updated. --- bec_widgets/utils/colors.py | 8 ++++++++ tests/unit_tests/conftest.py | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 6f58b10d9..34af4b82b 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -7,6 +7,7 @@ import pyqtgraph as pg 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 @@ -38,11 +39,18 @@ def get_accent_colors() -> AccentColors | None: return QApplication.instance().theme.accent_colors +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 via the global theming API. This updates QSS, QPalette, and pyqtgraph globally. """ + process_all_deferred_deletes(QApplication.instance()) apply_theme_global(theme) + process_all_deferred_deletes(QApplication.instance()) class Colors: diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index b1e858c4a..50ce1f84b 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -7,6 +7,7 @@ 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 @@ -23,9 +24,15 @@ 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() From 6e4b669b3a96ccb62c652c23f091ba0e6018acbb Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Fri, 29 Aug 2025 15:39:58 +0200 Subject: [PATCH 39/45] feat: add SafeConnect --- bec_widgets/utils/bec_widget.py | 9 +++--- bec_widgets/utils/error_popups.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 67a4c5e9e..34a80ee2a 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -11,7 +11,7 @@ from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig -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 @@ -61,7 +61,6 @@ def __init__( ) if not isinstance(self, QObject): raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") - if theme_update: logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") self._connect_to_theme_change() @@ -70,10 +69,10 @@ def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() if hasattr(qapp, "theme"): - qapp.theme.theme_changed.connect(self._update_theme) + SafeConnect(self, qapp.theme.theme_changed, self._update_theme) - @SafeSlot(str, verify_sender=True) - @SafeSlot(verify_sender=True) + @SafeSlot(str) + @SafeSlot() def _update_theme(self, theme: str | None = None): """Update the theme.""" if theme is None: diff --git a/bec_widgets/utils/error_popups.py b/bec_widgets/utils/error_popups.py index d2ead3dd1..730fcdce6 100644 --- a/bec_widgets/utils/error_popups.py +++ b/bec_widgets/utils/error_popups.py @@ -2,7 +2,9 @@ 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 __call__(self): 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 From 4e172a8eddaa1973785ad86e8cba59975f175988 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 1 Sep 2025 11:19:05 +0200 Subject: [PATCH 40/45] 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. --- tests/unit_tests/test_round_frame.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/unit_tests/test_round_frame.py b/tests/unit_tests/test_round_frame.py index ba46219e6..18c7152e5 100644 --- a/tests/unit_tests/test_round_frame.py +++ b/tests/unit_tests/test_round_frame.py @@ -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") From 0e356e0ce93f6a2028679dc32a38030463e41ecb Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 4 Sep 2025 16:30:59 +0200 Subject: [PATCH 41/45] feat(main_app): main app with interactive app switcher --- bec_widgets/applications/main_app.py | 118 ++++++ .../navigation_centre/__init__.py | 0 .../navigation_centre/reveal_animator.py | 114 ++++++ .../navigation_centre/side_bar.py | 355 +++++++++++++++++ .../navigation_centre/side_bar_components.py | 372 ++++++++++++++++++ .../advanced_dock_area/advanced_dock_area.py | 2 + tests/unit_tests/test_app_side_bar.py | 189 +++++++++ tests/unit_tests/test_reveal_animator.py | 128 ++++++ 8 files changed, 1278 insertions(+) create mode 100644 bec_widgets/applications/main_app.py create mode 100644 bec_widgets/applications/navigation_centre/__init__.py create mode 100644 bec_widgets/applications/navigation_centre/reveal_animator.py create mode 100644 bec_widgets/applications/navigation_centre/side_bar.py create mode 100644 bec_widgets/applications/navigation_centre/side_bar_components.py create mode 100644 tests/unit_tests/test_app_side_bar.py create mode 100644 tests/unit_tests/test_reveal_animator.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py new file mode 100644 index 000000000..2a6ebd2dc --- /dev/null +++ b/bec_widgets/applications/main_app.py @@ -0,0 +1,118 @@ +from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget + +from bec_widgets.applications.navigation_centre.side_bar import SideBar +from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem +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 BECMainApp(BECMainWindow): + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + # --- Compose central UI (sidebar + stack) + self.sidebar = SideBar(parent=self) + self.stack = QStackedWidget(self) + + container = QWidget(self) + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.sidebar, 0) + layout.addWidget(self.stack, 1) + self.setCentralWidget(container) + + # Mapping for view switching + self._view_index: dict[str, int] = {} + self.sidebar.view_selected.connect(self._on_view_selected) + + self._add_views() + + def _add_views(self): + self.add_section("BEC Applications", "bec_apps") + self.ads = AdvancedDockArea(self) + self.add_view( + icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" + ) + self.set_current("dock_area") + self.sidebar.add_dark_mode_item() + + # --- Public API ------------------------------------------------------ + def add_section(self, title: str, id: str, position: int | None = None): + return self.sidebar.add_section(title, id, position) + + def add_separator(self): + return self.sidebar.add_separator() + + def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None): + return self.sidebar.add_dark_mode_item(id=id, position=position) + + def add_view( + self, + *, + icon: str, + title: str, + id: str, + widget: QWidget, + mini_text: str | None = None, + position: int | None = None, + from_top: bool = True, + toggleable: bool = True, + exclusive: bool = True, + ) -> NavigationItem: + """ + Register a view in the stack and create a matching nav item in the sidebar. + + Args: + icon(str): Icon name for the nav item. + title(str): Title for the nav item. + id(str): Unique ID for the view/item. + widget(QWidget): The widget to add to the stack. + mini_text(str, optional): Short text for the nav item when sidebar is collapsed. + position(int, optional): Position to insert the nav item. + from_top(bool, optional): Whether to count position from the top or bottom. + toggleable(bool, optional): Whether the nav item is toggleable. + exclusive(bool, optional): Whether the nav item is exclusive. + + Returns: + NavigationItem: The created navigation item. + + + """ + item = self.sidebar.add_item( + icon=icon, + title=title, + id=id, + mini_text=mini_text, + position=position, + from_top=from_top, + toggleable=toggleable, + exclusive=exclusive, + ) + idx = self.stack.addWidget(widget) + self._view_index[id] = idx + return item + + def set_current(self, id: str) -> None: + if id in self._view_index: + self.sidebar.activate_item(id) + self._on_view_selected(id) + + # Internal: route sidebar selection to the stack + def _on_view_selected(self, vid: str) -> None: + idx = self._view_index.get(vid) + if idx is not None and 0 <= idx < self.stack.count(): + self.stack.setCurrentIndex(idx) + + +if __name__ == "__main__": # pragma: no cover + + import sys + + app = QApplication(sys.argv) + apply_theme("dark") + w = BECMainApp() + w.show() + + sys.exit(app.exec()) diff --git a/bec_widgets/applications/navigation_centre/__init__.py b/bec_widgets/applications/navigation_centre/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/navigation_centre/reveal_animator.py b/bec_widgets/applications/navigation_centre/reveal_animator.py new file mode 100644 index 000000000..714f69da3 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/reveal_animator.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation +from qtpy.QtWidgets import QGraphicsOpacityEffect, QWidget + +ANIMATION_DURATION = 500 # ms + + +class RevealAnimator: + """Animate reveal/hide for a single widget using opacity + max W/H. + + This keeps the widget always visible to avoid jitter from setVisible(). + Collapsed state: opacity=0, maxW=0, maxH=0. + Expanded state: opacity=1, maxW=sizeHint.width(), maxH=sizeHint.height(). + """ + + def __init__( + self, + widget: QWidget, + duration: int = ANIMATION_DURATION, + easing: QEasingCurve.Type = QEasingCurve.InOutCubic, + initially_revealed: bool = False, + *, + animate_opacity: bool = True, + animate_width: bool = True, + animate_height: bool = True, + ): + self.widget = widget + self.animate_opacity = animate_opacity + self.animate_width = animate_width + self.animate_height = animate_height + # Opacity effect + self.fx = QGraphicsOpacityEffect(widget) + widget.setGraphicsEffect(self.fx) + # Animations + self.opacity_anim = ( + QPropertyAnimation(self.fx, b"opacity") if self.animate_opacity else None + ) + self.width_anim = ( + QPropertyAnimation(widget, b"maximumWidth") if self.animate_width else None + ) + self.height_anim = ( + QPropertyAnimation(widget, b"maximumHeight") if self.animate_height else None + ) + for anim in (self.opacity_anim, self.width_anim, self.height_anim): + if anim is not None: + anim.setDuration(duration) + anim.setEasingCurve(easing) + # Initialize to requested state + self.set_immediate(initially_revealed) + + def _natural_sizes(self) -> tuple[int, int]: + sh = self.widget.sizeHint() + w = max(sh.width(), 1) + h = max(sh.height(), 1) + return w, h + + def set_immediate(self, revealed: bool): + """ + Immediately set the widget to the target revealed/collapsed state. + + Args: + revealed(bool): True to reveal, False to collapse. + """ + w, h = self._natural_sizes() + if self.animate_opacity: + self.fx.setOpacity(1.0 if revealed else 0.0) + if self.animate_width: + self.widget.setMaximumWidth(w if revealed else 0) + if self.animate_height: + self.widget.setMaximumHeight(h if revealed else 0) + + def setup(self, reveal: bool): + """ + Prepare animations to transition to the target revealed/collapsed state. + + Args: + reveal(bool): True to reveal, False to collapse. + """ + # Prepare animations from current state to target + target_w, target_h = self._natural_sizes() + if self.opacity_anim is not None: + self.opacity_anim.setStartValue(self.fx.opacity()) + self.opacity_anim.setEndValue(1.0 if reveal else 0.0) + if self.width_anim is not None: + self.width_anim.setStartValue(self.widget.maximumWidth()) + self.width_anim.setEndValue(target_w if reveal else 0) + if self.height_anim is not None: + self.height_anim.setStartValue(self.widget.maximumHeight()) + self.height_anim.setEndValue(target_h if reveal else 0) + + def add_to_group(self, group: QParallelAnimationGroup): + """ + Add the prepared animations to the given animation group. + + Args: + group(QParallelAnimationGroup): The animation group to add to. + """ + if self.opacity_anim is not None: + group.addAnimation(self.opacity_anim) + if self.width_anim is not None: + group.addAnimation(self.width_anim) + if self.height_anim is not None: + group.addAnimation(self.height_anim) + + def animations(self): + """ + Get a list of all animations (non-None) for adding to a group. + """ + return [ + anim + for anim in (self.opacity_anim, self.height_anim, self.width_anim) + if anim is not None + ] diff --git a/bec_widgets/applications/navigation_centre/side_bar.py b/bec_widgets/applications/navigation_centre/side_bar.py new file mode 100644 index 000000000..9bfff79f4 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/side_bar.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy import QtWidgets +from qtpy.QtCore import QEasingCurve, QParallelAnimationGroup, QPropertyAnimation, Qt, Signal +from qtpy.QtWidgets import ( + QGraphicsOpacityEffect, + QHBoxLayout, + QLabel, + QScrollArea, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets import SafeProperty, SafeSlot +from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION +from bec_widgets.applications.navigation_centre.side_bar_components import ( + DarkModeNavItem, + NavigationItem, + SectionHeader, + SideBarSeparator, +) + + +class SideBar(QScrollArea): + view_selected = Signal(str) + toggled = Signal(bool) + + def __init__( + self, + parent=None, + title: str = "Control Panel", + collapsed_width: int = 56, + expanded_width: int = 200, + anim_duration: int = ANIMATION_DURATION, + ): + super().__init__(parent=parent) + self.setObjectName("SideBar") + + # private attributes + self._is_expanded = False + self._collapsed_width = collapsed_width + self._expanded_width = expanded_width + self._anim_duration = anim_duration + + # containers + self.components = {} + self._item_opts: dict[str, dict] = {} + + # Scroll area properties + self.setWidgetResizable(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setFrameShape(QtWidgets.QFrame.NoFrame) + self.setFixedWidth(self._collapsed_width) + + # Content widget holding buttons for switching views + self.content = QWidget(self) + self.content_layout = QVBoxLayout(self.content) + self.content_layout.setContentsMargins(0, 0, 0, 0) + self.content_layout.setSpacing(2) + self.setWidget(self.content) + + # Track active navigation item + self._active_id = None + + # Top row with title and toggle button + self.toggle_row = QWidget(self) + self.toggle_row_layout = QHBoxLayout(self.toggle_row) + + self.title_label = QLabel(title, self) + self.title_label.setObjectName("TopTitle") + self.title_label.setStyleSheet("font-weight: 600;") + self.title_fx = QGraphicsOpacityEffect(self.title_label) + self.title_label.setGraphicsEffect(self.title_fx) + self.title_fx.setOpacity(0.0) + self.title_label.setVisible(False) # TODO dirty trick to avoid layout shift + + self.toggle = QToolButton(self) + self.toggle.setCheckable(False) + self.toggle.setIcon(material_icon("keyboard_arrow_right", convert_to_pixmap=False)) + self.toggle.clicked.connect(self.on_expand) + + self.toggle_row_layout.addWidget(self.title_label, 1, Qt.AlignLeft | Qt.AlignVCenter) + self.toggle_row_layout.addWidget(self.toggle, 1, Qt.AlignHCenter | Qt.AlignVCenter) + + # To push the content up always + self._bottom_spacer = QtWidgets.QSpacerItem( + 0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding + ) + + # Add core widgets to layout + self.content_layout.addWidget(self.toggle_row) + self.content_layout.addItem(self._bottom_spacer) + + # Animations + self.width_anim = QPropertyAnimation(self, b"bar_width") + self.width_anim.setDuration(self._anim_duration) + self.width_anim.setEasingCurve(QEasingCurve.InOutCubic) + + self.title_anim = QPropertyAnimation(self.title_fx, b"opacity") + self.title_anim.setDuration(self._anim_duration) + self.title_anim.setEasingCurve(QEasingCurve.InOutCubic) + + self.group = QParallelAnimationGroup(self) + self.group.addAnimation(self.width_anim) + self.group.addAnimation(self.title_anim) + self.group.finished.connect(self._on_anim_finished) + + app = QtWidgets.QApplication.instance() + if app is not None and hasattr(app, "theme") and hasattr(app.theme, "theme_changed"): + app.theme.theme_changed.connect(self._on_theme_changed) + + @SafeProperty(int) + def bar_width(self) -> int: + """ + Get the current width of the side bar. + + Returns: + int: The current width of the side bar. + """ + return self.width() + + @bar_width.setter + def bar_width(self, width: int): + """ + Set the width of the side bar. + + Args: + width(int): The new width of the side bar. + """ + self.setFixedWidth(width) + + @SafeProperty(bool) + def is_expanded(self) -> bool: + """ + Check if the side bar is expanded. + + Returns: + bool: True if the side bar is expanded, False otherwise. + """ + return self._is_expanded + + @SafeSlot() + @SafeSlot(bool) + def on_expand(self): + """ + Toggle the expansion state of the side bar. + """ + self._is_expanded = not self._is_expanded + self.toggle.setIcon( + material_icon( + "keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right", + convert_to_pixmap=False, + ) + ) + + if self._is_expanded: + self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignRight | Qt.AlignVCenter) + + self.group.stop() + # Setting limits for animations of the side bar + self.width_anim.setStartValue(self.width()) + self.width_anim.setEndValue( + self._expanded_width if self._is_expanded else self._collapsed_width + ) + self.title_anim.setStartValue(self.title_fx.opacity()) + self.title_anim.setEndValue(1.0 if self._is_expanded else 0.0) + + # Setting limits for animations of the components + for comp in self.components.values(): + if hasattr(comp, "setup_animations"): + comp.setup_animations(self._is_expanded) + + self.group.start() + if self._is_expanded: + # TODO do not like this trick, but it is what it is for now + self.title_label.setVisible(self._is_expanded) + for comp in self.components.values(): + if hasattr(comp, "set_visible"): + comp.set_visible(self._is_expanded) + self.toggled.emit(self._is_expanded) + + @SafeSlot() + def _on_anim_finished(self): + if not self._is_expanded: + self.toggle_row_layout.setAlignment(self.toggle, Qt.AlignHCenter | Qt.AlignVCenter) + # TODO do not like this trick, but it is what it is for now + self.title_label.setVisible(self._is_expanded) + for comp in self.components.values(): + if hasattr(comp, "set_visible"): + comp.set_visible(self._is_expanded) + + @SafeSlot(str) + def _on_theme_changed(self, theme_name: str): + # Refresh toggle arrow icon so it picks up the new theme + self.toggle.setIcon( + material_icon( + "keyboard_arrow_left" if self._is_expanded else "keyboard_arrow_right", + convert_to_pixmap=False, + ) + ) + # Refresh each component that supports it + for comp in self.components.values(): + if hasattr(comp, "refresh_theme"): + comp.refresh_theme() + else: + comp.style().unpolish(comp) + comp.style().polish(comp) + comp.update() + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def add_section(self, title: str, id: str, position: int | None = None) -> SectionHeader: + """ + Add a section header to the side bar. + + Args: + title(str): The title of the section. + id(str): Unique ID for the section. + position(int, optional): Position to insert the section header. + + Returns: + SectionHeader: The created section header. + + """ + header = SectionHeader(self, title, anim_duration=self._anim_duration) + position = position if position is not None else self.content_layout.count() - 1 + self.content_layout.insertWidget(position, header) + for anim in header.animations: + self.group.addAnimation(anim) + self.components[id] = header + return header + + def add_separator( + self, *, from_top: bool = True, position: int | None = None + ) -> SideBarSeparator: + """ + Add a separator line to the side bar. Separators are treated like regular + items; you can place multiple separators anywhere using `from_top` and `position`. + """ + line = SideBarSeparator(self) + line.setStyleSheet("margin:12px;") + self._insert_nav_item(line, from_top=from_top, position=position) + return line + + def add_item( + self, + icon: str, + title: str, + id: str, + mini_text: str | None = None, + position: int | None = None, + *, + from_top: bool = True, + toggleable: bool = True, + exclusive: bool = True, + ) -> NavigationItem: + """ + Add a navigation item to the side bar. + + Args: + icon(str): Icon name for the nav item. + title(str): Title for the nav item. + id(str): Unique ID for the nav item. + mini_text(str, optional): Short text for the nav item when sidebar is collapsed. + position(int, optional): Position to insert the nav item. + from_top(bool, optional): Whether to count position from the top or bottom. + toggleable(bool, optional): Whether the nav item is toggleable. + exclusive(bool, optional): Whether the nav item is exclusive. + + Returns: + NavigationItem: The created navigation item. + """ + item = NavigationItem( + parent=self, + title=title, + icon_name=icon, + mini_text=mini_text, + toggleable=toggleable, + exclusive=exclusive, + anim_duration=self._anim_duration, + ) + self._insert_nav_item(item, from_top=from_top, position=position) + for anim in item.build_animations(): + self.group.addAnimation(anim) + self.components[id] = item + # Connect activation to activation logic, passing id unchanged + item.activated.connect(lambda id=id: self.activate_item(id)) + return item + + def activate_item(self, target_id: str): + target = self.components.get(target_id) + if target is None: + return + # Non-toggleable acts like an action: do not change any toggled states + if hasattr(target, "toggleable") and not target.toggleable: + self._active_id = target_id + self.view_selected.emit(target_id) + return + + is_exclusive = getattr(target, "exclusive", True) + if is_exclusive: + # Radio-like behavior among exclusive items only + for comp_id, comp in self.components.items(): + if not isinstance(comp, NavigationItem): + continue + if comp is target: + comp.set_active(True) + else: + # Only untoggle other items that are also exclusive + if getattr(comp, "exclusive", True): + comp.set_active(False) + # Leave non-exclusive items as they are + else: + # Non-exclusive toggles independently + target.set_active(not target.is_active()) + + self._active_id = target_id + self.view_selected.emit(target_id) + + def add_dark_mode_item( + self, id: str = "dark_mode", position: int | None = None + ) -> DarkModeNavItem: + """ + Add a dark mode toggle item to the side bar. + + Args: + id(str): Unique ID for the dark mode item. + position(int, optional): Position to insert the dark mode item. + + Returns: + DarkModeNavItem: The created dark mode navigation item. + """ + item = DarkModeNavItem(parent=self, id=id, anim_duration=self._anim_duration) + # compute bottom insertion point (same semantics as from_top=False) + self._insert_nav_item(item, from_top=False, position=position) + for anim in item.build_animations(): + self.group.addAnimation(anim) + self.components[id] = item + item.activated.connect(lambda id=id: self.activate_item(id)) + return item + + def _insert_nav_item( + self, item: QWidget, *, from_top: bool = True, position: int | None = None + ): + if from_top: + base_index = self.content_layout.indexOf(self._bottom_spacer) + pos = base_index if position is None else min(base_index, position) + else: + base = self.content_layout.indexOf(self._bottom_spacer) + 1 + pos = base if position is None else base + max(0, position) + self.content_layout.insertWidget(pos, item) diff --git a/bec_widgets/applications/navigation_centre/side_bar_components.py b/bec_widgets/applications/navigation_centre/side_bar_components.py new file mode 100644 index 000000000..67bb7666f --- /dev/null +++ b/bec_widgets/applications/navigation_centre/side_bar_components.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy import QtCore +from qtpy.QtCore import QEasingCurve, QPropertyAnimation, Qt +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets import SafeProperty +from bec_widgets.applications.navigation_centre.reveal_animator import ( + ANIMATION_DURATION, + RevealAnimator, +) + + +def get_on_primary(): + app = QApplication.instance() + if app is not None and hasattr(app, "theme"): + return app.theme.color("ON_PRIMARY") + return "#FFFFFF" + + +def get_fg(): + app = QApplication.instance() + if app is not None and hasattr(app, "theme"): + return app.theme.color("FG") + return "#FFFFFF" + + +class SideBarSeparator(QFrame): + """A horizontal line separator for use in SideBar.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("SideBarSeparator") + self.setFrameShape(QFrame.NoFrame) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setFixedHeight(2) + self.setProperty("variant", "separator") + + +class SectionHeader(QWidget): + """A section header with a label and a horizontal line below.""" + + def __init__(self, parent=None, text: str = None, anim_duration: int = ANIMATION_DURATION): + super().__init__(parent) + self.setObjectName("SectionHeader") + + self.lbl = QLabel(text, self) + self.lbl.setObjectName("SectionHeaderLabel") + self.lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self._reveal = RevealAnimator(self.lbl, duration=anim_duration, initially_revealed=False) + + self.line = SideBarSeparator(self) + + lay = QVBoxLayout(self) + # keep your margins/spacing preferences here if needed + lay.setContentsMargins(12, 0, 12, 0) + lay.setSpacing(6) + lay.addWidget(self.lbl) + lay.addWidget(self.line) + + self.animations = self.build_animations() + + def build_animations(self) -> list[QPropertyAnimation]: + """ + Build and return animations for expanding/collapsing the sidebar. + + Returns: + list[QPropertyAnimation]: List of animations. + """ + return self._reveal.animations() + + def setup_animations(self, expanded: bool): + """ + Setup animations for expanding/collapsing the sidebar. + + Args: + expanded(bool): True if the sidebar is expanded, False if collapsed. + """ + self._reveal.setup(expanded) + + +class NavigationItem(QWidget): + """A nav tile with an icon + labels and an optional expandable body. + Provides animations for collapsed/expanded sidebar states via + build_animations()/setup_animations(), similar to SectionHeader. + """ + + activated = QtCore.Signal() + + def __init__( + self, + parent=None, + *, + title: str, + icon_name: str, + mini_text: str | None = None, + toggleable: bool = True, + exclusive: bool = True, + anim_duration: int = ANIMATION_DURATION, + ): + super().__init__(parent=parent) + self.setObjectName("NavigationItem") + + # Private attributes + self._title = title + self._icon_name = icon_name + self._mini_text = mini_text or title + self._toggleable = toggleable + self._toggled = False + self._exclusive = exclusive + + # Main Icon + self.icon_btn = QToolButton(self) + self.icon_btn.setIcon(material_icon(self._icon_name, filled=False, convert_to_pixmap=False)) + self.icon_btn.setAutoRaise(True) + self._icon_size_collapsed = QtCore.QSize(20, 20) + self._icon_size_expanded = QtCore.QSize(26, 26) + self.icon_btn.setIconSize(self._icon_size_collapsed) + # Remove QToolButton hover/pressed background/outline + self.icon_btn.setStyleSheet( + """ + QToolButton:hover { background: transparent; border: none; } + QToolButton:pressed { background: transparent; border: none; } + """ + ) + + # Mini label below icon + self.mini_lbl = QLabel(self._mini_text, self) + self.mini_lbl.setObjectName("NavMiniLabel") + self.mini_lbl.setAlignment(Qt.AlignCenter) + self.mini_lbl.setStyleSheet("font-size: 10px;") + self.reveal_mini_lbl = RevealAnimator( + widget=self.mini_lbl, + initially_revealed=True, + animate_width=False, + duration=anim_duration, + ) + + # Container for icon + mini label + self.mini_icon = QWidget(self) + mini_lay = QVBoxLayout(self.mini_icon) + mini_lay.setContentsMargins(0, 2, 0, 2) + mini_lay.setSpacing(2) + mini_lay.addWidget(self.icon_btn, 0, Qt.AlignCenter) + mini_lay.addWidget(self.mini_lbl, 0, Qt.AlignCenter) + + # Title label + self.title_lbl = QLabel(self._title, self) + self.title_lbl.setObjectName("NavTitleLabel") + self.title_lbl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.title_lbl.setStyleSheet("font-size: 13px;") + self.reveal_title_lbl = RevealAnimator( + widget=self.title_lbl, + initially_revealed=False, + animate_height=False, + duration=anim_duration, + ) + self.title_lbl.setVisible(False) # TODO dirty trick to avoid layout shift + + lay = QHBoxLayout(self) + lay.setContentsMargins(12, 2, 12, 2) + lay.setSpacing(6) + lay.addWidget(self.mini_icon, 0, Qt.AlignHCenter | Qt.AlignTop) + lay.addWidget(self.title_lbl, 1, Qt.AlignLeft | Qt.AlignVCenter) + + self.icon_size_anim = QPropertyAnimation(self.icon_btn, b"iconSize") + self.icon_size_anim.setDuration(anim_duration) + self.icon_size_anim.setEasingCurve(QEasingCurve.InOutCubic) + + # Connect icon button to emit activation + self.icon_btn.clicked.connect(self._emit_activated) + self.setMouseTracking(True) + self.setAttribute(Qt.WA_StyledBackground, True) + + def is_active(self) -> bool: + """Return whether the item is currently active/selected.""" + return self.property("toggled") is True + + def build_animations(self) -> list[QPropertyAnimation]: + """ + Build and return animations for expanding/collapsing the sidebar. + + Returns: + list[QPropertyAnimation]: List of animations. + """ + return ( + self.reveal_title_lbl.animations() + + self.reveal_mini_lbl.animations() + + [self.icon_size_anim] + ) + + def setup_animations(self, expanded: bool): + """ + Setup animations for expanding/collapsing the sidebar. + + Args: + expanded(bool): True if the sidebar is expanded, False if collapsed. + """ + self.reveal_mini_lbl.setup(not expanded) + self.reveal_title_lbl.setup(expanded) + self.icon_size_anim.setStartValue(self.icon_btn.iconSize()) + self.icon_size_anim.setEndValue( + self._icon_size_expanded if expanded else self._icon_size_collapsed + ) + + def set_visible(self, visible: bool): + """Set visibility of the title label.""" + self.title_lbl.setVisible(visible) + + def _emit_activated(self): + self.activated.emit() + + def set_active(self, active: bool): + """ + Set the active/selected state of the item. + + Args: + active(bool): True to set active, False to deactivate. + """ + self.setProperty("toggled", active) + self.toggled = active + # ensure style refresh + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def mousePressEvent(self, event): + self.activated.emit() + super().mousePressEvent(event) + + @SafeProperty(bool) + def toggleable(self) -> bool: + """ + Whether the item is toggleable (like a button) or not (like an action). + + Returns: + bool: True if toggleable, False otherwise. + """ + return self._toggleable + + @toggleable.setter + def toggleable(self, value: bool): + """ + Set whether the item is toggleable (like a button) or not (like an action). + Args: + value(bool): True to make toggleable, False otherwise. + """ + self._toggleable = bool(value) + + @SafeProperty(bool) + def toggled(self) -> bool: + """ + Whether the item is currently toggled/selected. + + Returns: + bool: True if toggled, False otherwise. + """ + return self._toggled + + @toggled.setter + def toggled(self, value: bool): + """ + Set whether the item is currently toggled/selected. + + Args: + value(bool): True to set toggled, False to untoggle. + """ + self._toggled = value + if value: + new_icon = material_icon( + self._icon_name, filled=True, color=get_on_primary(), convert_to_pixmap=False + ) + else: + new_icon = material_icon( + self._icon_name, filled=False, color=get_fg(), convert_to_pixmap=False + ) + self.icon_btn.setIcon(new_icon) + # Re-polish so QSS applies correct colors to icon/labels + for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl): + w.style().unpolish(w) + w.style().polish(w) + w.update() + + @SafeProperty(bool) + def exclusive(self) -> bool: + """ + Whether the item is exclusive in its toggle group. + + Returns: + bool: True if exclusive, False otherwise. + """ + return self._exclusive + + @exclusive.setter + def exclusive(self, value: bool): + """ + Set whether the item is exclusive in its toggle group. + + Args: + value(bool): True to make exclusive, False otherwise. + """ + self._exclusive = bool(value) + + def refresh_theme(self): + # Recompute icon/label colors according to current theme and state + # Trigger the toggled setter to rebuild the icon with the correct color + self.toggled = self._toggled + # Ensure QSS-driven text/icon colors refresh + for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl): + w.style().unpolish(w) + w.style().polish(w) + w.update() + + +class DarkModeNavItem(NavigationItem): + """Bottom action item that toggles app theme and updates its icon/text.""" + + def __init__( + self, parent=None, *, id: str = "dark_mode", anim_duration: int = ANIMATION_DURATION + ): + super().__init__( + parent=parent, + title="Dark mode", + icon_name="dark_mode", + mini_text="Dark", + toggleable=False, # action-like, no selection highlight changes + exclusive=False, + anim_duration=anim_duration, + ) + self._id = id + self._sync_from_qapp_theme() + self.activated.connect(self.toggle_theme) + + def _qapp_dark_enabled(self) -> bool: + qapp = QApplication.instance() + return bool(getattr(getattr(qapp, "theme", None), "theme", None) == "dark") + + def _sync_from_qapp_theme(self): + is_dark = self._qapp_dark_enabled() + # Update labels + self.title_lbl.setText("Light mode" if is_dark else "Dark mode") + self.mini_lbl.setText("Light" if is_dark else "Dark") + # Update icon + self.icon_btn.setIcon( + material_icon("light_mode" if is_dark else "dark_mode", convert_to_pixmap=False) + ) + + def refresh_theme(self): + self._sync_from_qapp_theme() + for w in (self, self.icon_btn, self.title_lbl, self.mini_lbl): + w.style().unpolish(w) + w.style().polish(w) + w.update() + + def toggle_theme(self): + """Toggle application theme and update icon/text.""" + from bec_widgets.utils.colors import apply_theme + + is_dark = self._qapp_dark_enabled() + + apply_theme("light" if is_dark else "dark") + self._sync_from_qapp_theme() diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 5b3f5a93e..4b0e11f9a 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -25,6 +25,7 @@ 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.colors import apply_theme from bec_widgets.utils.property_editor import PropertyEditor from bec_widgets.utils.toolbars.actions import ( ExpandableMenuAction, @@ -901,6 +902,7 @@ def cleanup(self): import sys app = QApplication(sys.argv) + apply_theme("dark") dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() ads = AdvancedDockArea(mode="developer", root_widget=True) diff --git a/tests/unit_tests/test_app_side_bar.py b/tests/unit_tests/test_app_side_bar.py new file mode 100644 index 000000000..830844ac7 --- /dev/null +++ b/tests/unit_tests/test_app_side_bar.py @@ -0,0 +1,189 @@ +import pytest +from qtpy.QtCore import QParallelAnimationGroup, QSize + +from bec_widgets.applications.navigation_centre.side_bar import SideBar +from bec_widgets.applications.navigation_centre.side_bar_components import ( + NavigationItem, + SectionHeader, +) + +ANIM_TEST_DURATION = 60 # ms + + +def _run(group: QParallelAnimationGroup, qtbot, duration=ANIM_TEST_DURATION): + group.start() + qtbot.wait(duration + 100) + + +@pytest.fixture +def header(qtbot): + w = SectionHeader(text="Group", anim_duration=ANIM_TEST_DURATION) + qtbot.addWidget(w) + qtbot.waitExposed(w) + return w + + +def test_section_header_initial_state_collapsed(header): + # RevealAnimator is initially collapsed for the label + assert header.lbl.maximumWidth() == 0 + assert header.lbl.maximumHeight() == 0 + + +def test_section_header_animates_reveal_and_hide(header, qtbot): + group = QParallelAnimationGroup() + for anim in header.build_animations(): + group.addAnimation(anim) + + # Expand + header.setup_animations(True) + _run(group, qtbot) + sh = header.lbl.sizeHint() + assert header.lbl.maximumWidth() >= sh.width() + assert header.lbl.maximumHeight() >= sh.height() + + # Collapse + header.setup_animations(False) + _run(group, qtbot) + assert header.lbl.maximumWidth() == 0 + assert header.lbl.maximumHeight() == 0 + + +@pytest.fixture +def nav(qtbot): + w = NavigationItem( + title="Counter", icon_name="widgets", mini_text="cnt", anim_duration=ANIM_TEST_DURATION + ) + qtbot.addWidget(w) + qtbot.waitExposed(w) + return w + + +def test_build_animations_contains(nav): + lst = nav.build_animations() + assert len(lst) == 5 + + +def test_setup_animations_changes_targets(nav, qtbot): + group = QParallelAnimationGroup() + for a in nav.build_animations(): + group.addAnimation(a) + + # collapsed -> expanded + nav.setup_animations(True) + _run(group, qtbot) + + sh_title = nav.title_lbl.sizeHint() + assert nav.title_lbl.maximumWidth() >= sh_title.width() + assert nav.mini_lbl.maximumHeight() == 0 + assert nav.icon_btn.iconSize() == QSize(26, 26) + + # expanded -> collapsed + nav.setup_animations(False) + _run(group, qtbot) + assert nav.title_lbl.maximumWidth() == 0 + sh_mini = nav.mini_lbl.sizeHint() + assert nav.mini_lbl.maximumHeight() >= sh_mini.height() + assert nav.icon_btn.iconSize() == QSize(20, 20) + + +def test_activation_signal_emits(nav, qtbot): + with qtbot.waitSignal(nav.activated, timeout=1000): + nav.icon_btn.click() + + +@pytest.fixture +def sidebar(qtbot): + sb = SideBar(title="Controls", anim_duration=ANIM_TEST_DURATION) + qtbot.addWidget(sb) + qtbot.waitExposed(sb) + return sb + + +def test_add_section_and_separator(sidebar): + sec = sidebar.add_section("Group A", id="group_a") + assert sec is not None + sep = sidebar.add_separator() + assert sep is not None + assert sidebar.content_layout.indexOf(sep) != -1 + + +def test_add_item_top_and_bottom_positions(sidebar): + top_item = sidebar.add_item(icon="widgets", title="Top", id="top") + bottom_item = sidebar.add_item(icon="widgets", title="Bottom", id="bottom", from_top=False) + + i_spacer = sidebar.content_layout.indexOf(sidebar._bottom_spacer) + i_top = sidebar.content_layout.indexOf(top_item) + i_bottom = sidebar.content_layout.indexOf(bottom_item) + + assert i_top != -1 and i_bottom != -1 + assert i_bottom > i_spacer # bottom items go after the spacer + + +def test_selection_exclusive_and_nonexclusive(sidebar, qtbot): + a = sidebar.add_item(icon="widgets", title="A", id="a", exclusive=True) + b = sidebar.add_item(icon="widgets", title="B", id="b", exclusive=True) + c = sidebar.add_item(icon="widgets", title="C", id="c", exclusive=False) + + c._emit_activated() + qtbot.wait(10) + assert c.is_active() is True + + a._emit_activated() + qtbot.wait(10) + assert a.is_active() is True + assert b.is_active() is False + assert c.is_active() is True + + b._emit_activated() + qtbot.wait(200) + assert a.is_active() is False + assert b.is_active() is True + assert c.is_active() is True + + +def test_on_expand_configures_targets_and_shows_title(sidebar, qtbot): + # Start collapsed + assert sidebar._is_expanded is False + start_w = sidebar.width() + + sidebar.on_expand() + + assert sidebar.width_anim.startValue() == start_w + assert sidebar.width_anim.endValue() == sidebar._expanded_width + assert sidebar.title_anim.endValue() == 1.0 + + +def test__on_anim_finished_hides_on_collapse_and_resets_alignment(sidebar, qtbot): + # Add one item so set_visible is called on components too + item = sidebar.add_item(icon="widgets", title="Item", id="item") + + # Expand first + sidebar.on_expand() + qtbot.wait(ANIM_TEST_DURATION + 150) + assert sidebar._is_expanded is True + + # Now collapse + sidebar.on_expand() + # Wait for animation group to finish and _on_anim_finished to run + with qtbot.waitSignal(sidebar.group.finished, timeout=2000): + pass + + # Collapsed state + assert sidebar._is_expanded is False + + +def test_dark_mode_item_is_action(sidebar, qtbot, monkeypatch): + dm = sidebar.add_dark_mode_item() + + called = {"toggled": False} + + def fake_apply(theme): + called["toggled"] = True + + monkeypatch.setattr("bec_widgets.utils.colors.apply_theme", fake_apply, raising=False) + + before = dm.is_active() + dm._emit_activated() + qtbot.wait(200) + assert called["toggled"] is True + assert dm.is_active() == before diff --git a/tests/unit_tests/test_reveal_animator.py b/tests/unit_tests/test_reveal_animator.py new file mode 100644 index 000000000..5704eb6ef --- /dev/null +++ b/tests/unit_tests/test_reveal_animator.py @@ -0,0 +1,128 @@ +import pytest +from qtpy.QtCore import QParallelAnimationGroup +from qtpy.QtWidgets import QLabel + +from bec_widgets.applications.navigation_centre.reveal_animator import RevealAnimator + +ANIM_TEST_DURATION = 50 # ms + + +@pytest.fixture +def label(qtbot): + w = QLabel("Reveal Label") + qtbot.addWidget(w) + qtbot.waitExposed(w) + return w + + +def _run_group(group: QParallelAnimationGroup, qtbot, duration_ms: int): + group.start() + qtbot.wait(duration_ms + 100) + + +def test_immediate_collapsed_then_revealed(label): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, initially_revealed=False) + + # Initially collapsed + assert anim.fx.opacity() == pytest.approx(0.0) + assert label.maximumWidth() == 0 + assert label.maximumHeight() == 0 + + # Snap to revealed + anim.set_immediate(True) + sh = label.sizeHint() + assert anim.fx.opacity() == pytest.approx(1.0) + assert label.maximumWidth() == max(sh.width(), 1) + assert label.maximumHeight() == max(sh.height(), 1) + + +def test_reveal_then_collapse_with_animation(label, qtbot): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, initially_revealed=False) + + group = QParallelAnimationGroup() + anim.setup(True) + anim.add_to_group(group) + _run_group(group, qtbot, ANIM_TEST_DURATION) + + sh = label.sizeHint() + assert anim.fx.opacity() == pytest.approx(1.0) + assert label.maximumWidth() == max(sh.width(), 1) + assert label.maximumHeight() == max(sh.height(), 1) + + # Collapse using the SAME group; do not re-add animations to avoid deletion + anim.setup(False) + _run_group(group, qtbot, ANIM_TEST_DURATION) + + assert anim.fx.opacity() == pytest.approx(0.0) + assert label.maximumWidth() == 0 + assert label.maximumHeight() == 0 + + +@pytest.mark.parametrize( + "flags", + [ + dict(animate_opacity=False, animate_width=True, animate_height=True), + dict(animate_opacity=True, animate_width=False, animate_height=True), + dict(animate_opacity=True, animate_width=True, animate_height=False), + ], +) +def test_partial_flags_respectively_disable_properties(label, qtbot, flags): + # Establish initial state + label.setMaximumWidth(123) + label.setMaximumHeight(456) + + anim = RevealAnimator(label, duration=10, initially_revealed=False, **flags) + + # Record baseline values for disabled properties + baseline_opacity = anim.fx.opacity() + baseline_w = label.maximumWidth() + baseline_h = label.maximumHeight() + + group = QParallelAnimationGroup() + anim.setup(True) + anim.add_to_group(group) + _run_group(group, qtbot, ANIM_TEST_DURATION) + + sh = label.sizeHint() + + if flags.get("animate_opacity", True): + assert anim.fx.opacity() == pytest.approx(1.0) + else: + # Opacity should remain unchanged + assert anim.fx.opacity() == pytest.approx(baseline_opacity) + + if flags.get("animate_width", True): + assert label.maximumWidth() == max(sh.width(), 1) + else: + assert label.maximumWidth() == baseline_w + + if flags.get("animate_height", True): + assert label.maximumHeight() == max(sh.height(), 1) + else: + assert label.maximumHeight() == baseline_h + + +def test_animations_list_and_order(label): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION) + lst = anim.animations() + # All should be present and in defined order: opacity, height, width + names = [a.propertyName() for a in lst] + assert names == [b"opacity", b"maximumHeight", b"maximumWidth"] + + +@pytest.mark.parametrize( + "flags,expected", + [ + (dict(animate_opacity=False), [b"maximumHeight", b"maximumWidth"]), + (dict(animate_width=False), [b"opacity", b"maximumHeight"]), + (dict(animate_height=False), [b"opacity", b"maximumWidth"]), + (dict(animate_opacity=False, animate_width=False, animate_height=True), [b"maximumHeight"]), + (dict(animate_opacity=False, animate_width=True, animate_height=False), [b"maximumWidth"]), + (dict(animate_opacity=True, animate_width=False, animate_height=False), [b"opacity"]), + (dict(animate_opacity=False, animate_width=False, animate_height=False), []), + ], +) +def test_animations_respects_flags(label, flags, expected): + anim = RevealAnimator(label, duration=ANIM_TEST_DURATION, **flags) + names = [a.propertyName() for a in anim.animations()] + assert names == expected From eaf1634ca526b214ada7ad9234f4b14a2a6383f8 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 10 Sep 2025 15:04:57 +0200 Subject: [PATCH 42/45] feat(main_app):views with examples for enter and exit hook --- bec_widgets/applications/main_app.py | 89 +++++- .../navigation_centre/side_bar.py | 12 +- bec_widgets/applications/views/__init__.py | 0 bec_widgets/applications/views/view.py | 262 ++++++++++++++++++ 4 files changed, 349 insertions(+), 14 deletions(-) create mode 100644 bec_widgets/applications/views/__init__.py create mode 100644 bec_widgets/applications/views/view.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 2a6ebd2dc..791f07519 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -1,18 +1,29 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget +from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION from bec_widgets.applications.navigation_centre.side_bar import SideBar from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem +from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup 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 BECMainApp(BECMainWindow): - def __init__(self, parent=None, *args, **kwargs): + + def __init__( + self, + parent=None, + *args, + anim_duration: int = ANIMATION_DURATION, + show_examples: bool = False, + **kwargs, + ): super().__init__(parent=parent, *args, **kwargs) + self._show_examples = bool(show_examples) # --- Compose central UI (sidebar + stack) - self.sidebar = SideBar(parent=self) + self.sidebar = SideBar(parent=self, anim_duration=anim_duration) self.stack = QStackedWidget(self) container = QWidget(self) @@ -25,6 +36,7 @@ def __init__(self, parent=None, *args, **kwargs): # Mapping for view switching self._view_index: dict[str, int] = {} + self._current_view_id: str | None = None self.sidebar.view_selected.connect(self._on_view_selected) self._add_views() @@ -32,9 +44,35 @@ def __init__(self, parent=None, *args, **kwargs): def _add_views(self): self.add_section("BEC Applications", "bec_apps") self.ads = AdvancedDockArea(self) + self.add_view( icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" ) + + if self._show_examples: + self.add_section("Examples", "examples") + waveform_view_popup = WaveformViewPopup( + parent=self, id="waveform_view_popup", title="Waveform Plot" + ) + waveform_view_stack = WaveformViewInline( + parent=self, id="waveform_view_stack", title="Waveform Plot" + ) + + self.add_view( + icon="show_chart", + title="Waveform With Popup", + id="waveform_popup", + widget=waveform_view_popup, + mini_text="Popup", + ) + self.add_view( + icon="show_chart", + title="Waveform InLine Stack", + id="waveform_stack", + widget=waveform_view_stack, + mini_text="Stack", + ) + self.set_current("dock_area") self.sidebar.add_dark_mode_item() @@ -90,29 +128,62 @@ def add_view( toggleable=toggleable, exclusive=exclusive, ) - idx = self.stack.addWidget(widget) + # Wrap plain widgets into a ViewBase so enter/exit hooks are available + if isinstance(widget, ViewBase): + view_widget = widget + else: + view_widget = ViewBase(content=widget, parent=self, id=id, title=title) + + idx = self.stack.addWidget(view_widget) self._view_index[id] = idx return item def set_current(self, id: str) -> None: if id in self._view_index: self.sidebar.activate_item(id) - self._on_view_selected(id) # Internal: route sidebar selection to the stack def _on_view_selected(self, vid: str) -> None: + # Determine current view + current_index = self.stack.currentIndex() + current_view = ( + self.stack.widget(current_index) if 0 <= current_index < self.stack.count() else None + ) + + # Ask current view whether we may leave + if current_view is not None and hasattr(current_view, "on_exit"): + may_leave = current_view.on_exit() + if may_leave is False: + # Veto: restore previous highlight without re-emitting selection + if self._current_view_id is not None: + self.sidebar.activate_item(self._current_view_id, emit_signal=False) + return + + # Proceed with switch idx = self._view_index.get(vid) - if idx is not None and 0 <= idx < self.stack.count(): - self.stack.setCurrentIndex(idx) + if idx is None or not (0 <= idx < self.stack.count()): + return + self.stack.setCurrentIndex(idx) + new_view = self.stack.widget(idx) + self._current_view_id = vid + if hasattr(new_view, "on_enter"): + new_view.on_enter() if __name__ == "__main__": # pragma: no cover - + import argparse import sys - app = QApplication(sys.argv) + parser = argparse.ArgumentParser(description="BEC Main Application") + parser.add_argument( + "--examples", action="store_true", help="Show the Examples section with waveform demo views" + ) + # Let Qt consume the remaining args + args, qt_args = parser.parse_known_args(sys.argv[1:]) + + app = QApplication([sys.argv[0], *qt_args]) apply_theme("dark") - w = BECMainApp() + w = BECMainApp(show_examples=args.examples) w.show() sys.exit(app.exec()) diff --git a/bec_widgets/applications/navigation_centre/side_bar.py b/bec_widgets/applications/navigation_centre/side_bar.py index 9bfff79f4..6354cafe2 100644 --- a/bec_widgets/applications/navigation_centre/side_bar.py +++ b/bec_widgets/applications/navigation_centre/side_bar.py @@ -32,7 +32,7 @@ def __init__( parent=None, title: str = "Control Panel", collapsed_width: int = 56, - expanded_width: int = 200, + expanded_width: int = 250, anim_duration: int = ANIMATION_DURATION, ): super().__init__(parent=parent) @@ -59,7 +59,7 @@ def __init__( self.content = QWidget(self) self.content_layout = QVBoxLayout(self.content) self.content_layout.setContentsMargins(0, 0, 0, 0) - self.content_layout.setSpacing(2) + self.content_layout.setSpacing(4) self.setWidget(self.content) # Track active navigation item @@ -291,14 +291,15 @@ def add_item( item.activated.connect(lambda id=id: self.activate_item(id)) return item - def activate_item(self, target_id: str): + def activate_item(self, target_id: str, *, emit_signal: bool = True): target = self.components.get(target_id) if target is None: return # Non-toggleable acts like an action: do not change any toggled states if hasattr(target, "toggleable") and not target.toggleable: self._active_id = target_id - self.view_selected.emit(target_id) + if emit_signal: + self.view_selected.emit(target_id) return is_exclusive = getattr(target, "exclusive", True) @@ -319,7 +320,8 @@ def activate_item(self, target_id: str): target.set_active(not target.is_active()) self._active_id = target_id - self.view_selected.emit(target_id) + if emit_signal: + self.view_selected.emit(target_id) def add_dark_mode_item( self, id: str = "dark_mode", position: int | None = None diff --git a/bec_widgets/applications/views/__init__.py b/bec_widgets/applications/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py new file mode 100644 index 000000000..3b98f7568 --- /dev/null +++ b/bec_widgets/applications/views/view.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +from qtpy.QtCore import QEventLoop +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QStackedLayout, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox +from bec_widgets.widgets.plots.waveform.waveform import Waveform + + +class ViewBase(QWidget): + """Wrapper for a content widget used inside the main app's stacked view. + + Subclasses can implement `on_enter` and `on_exit` to run custom logic when the view becomes visible or is about to be hidden. + + Args: + content (QWidget): The actual view widget to display. + parent (QWidget | None): Parent widget. + id (str | None): Optional view id, useful for debugging or introspection. + title (str | None): Optional human-readable title. + """ + + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent) + self.content: QWidget | None = None + self.view_id = id + self.view_title = title + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + + if content is not None: + self.set_content(content) + + def set_content(self, content: QWidget) -> None: + """Replace the current content widget with a new one.""" + if self.content is not None: + self.content.setParent(None) + self.content = content + self.layout().addWidget(content) + + @SafeSlot() + def on_enter(self) -> None: + """Called after the view becomes current/visible. + + Default implementation does nothing. Override in subclasses. + """ + pass + + @SafeSlot() + def on_exit(self) -> bool: + """Called before the view is switched away/hidden. + + Return True to allow switching, or False to veto. + Default implementation allows switching. + """ + return True + + +#################################################################################################### +# Example views for demonstration/testing purposes +#################################################################################################### + + +# --- Popup UI version --- +class WaveformViewPopup(ViewBase): # pragma: no cover + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + self.waveform = Waveform(parent=self) + self.set_content(self.waveform) + + @SafeSlot() + def on_enter(self) -> None: + dialog = QDialog(self) + dialog.setWindowTitle("Configure Waveform View") + + label = QLabel("Select device and signal for the waveform plot:", parent=dialog) + + # same as in the CurveRow used in waveform + self.device_edit = DeviceComboBox(parent=self) + self.device_edit.insertItem(0, "") + self.device_edit.setEditable(True) + self.device_edit.setCurrentIndex(0) + self.entry_edit = SignalComboBox(parent=self) + self.entry_edit.include_config_signals = False + self.entry_edit.insertItem(0, "") + self.entry_edit.setEditable(True) + self.device_edit.currentTextChanged.connect(self.entry_edit.set_device) + self.device_edit.device_reset.connect(self.entry_edit.reset_selection) + + form = QFormLayout() + form.addRow(label) + form.addRow("Device", self.device_edit) + form.addRow("Signal", self.entry_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dialog) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + + v = QVBoxLayout(dialog) + v.addLayout(form) + v.addWidget(buttons) + + if dialog.exec_() == QDialog.Accepted: + self.waveform.plot( + y_name=self.device_edit.currentText(), y_entry=self.entry_edit.currentText() + ) + + @SafeSlot() + def on_exit(self) -> bool: + ans = QMessageBox.question( + self, + "Switch and clear?", + "Do you want to switch views and clear the plot?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if ans == QMessageBox.Yes: + self.waveform.clear_all() + return True + return False + + +# --- Inline stacked UI version --- +class WaveformViewInline(ViewBase): # pragma: no cover + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent=parent, *args, **kwargs) + + # Root layout for this view uses a stacked layout + self.stack = QStackedLayout() + container = QWidget(self) + container.setLayout(self.stack) + self.set_content(container) + + # --- Page 0: Settings page (inline form) + self.settings_page = QWidget() + sp_layout = QVBoxLayout(self.settings_page) + sp_layout.setContentsMargins(16, 16, 16, 16) + sp_layout.setSpacing(12) + + title = QLabel("Select device and signal for the waveform plot:", parent=self.settings_page) + self.device_edit = DeviceComboBox(parent=self.settings_page) + self.device_edit.insertItem(0, "") + self.device_edit.setEditable(True) + self.device_edit.setCurrentIndex(0) + + self.entry_edit = SignalComboBox(parent=self.settings_page) + self.entry_edit.include_config_signals = False + self.entry_edit.insertItem(0, "") + self.entry_edit.setEditable(True) + self.device_edit.currentTextChanged.connect(self.entry_edit.set_device) + self.device_edit.device_reset.connect(self.entry_edit.reset_selection) + + form = QFormLayout() + form.addRow(title) + form.addRow("Device", self.device_edit) + form.addRow("Signal", self.entry_edit) + + btn_row = QHBoxLayout() + ok_btn = QPushButton("OK", parent=self.settings_page) + cancel_btn = QPushButton("Cancel", parent=self.settings_page) + btn_row.addStretch(1) + btn_row.addWidget(cancel_btn) + btn_row.addWidget(ok_btn) + + sp_layout.addLayout(form) + sp_layout.addLayout(btn_row) + + # --- Page 1: Waveform page + self.waveform_page = QWidget() + wf_layout = QVBoxLayout(self.waveform_page) + wf_layout.setContentsMargins(0, 0, 0, 0) + self.waveform = Waveform(parent=self.waveform_page) + wf_layout.addWidget(self.waveform) + + # --- Page 2: Exit confirmation page (inline) + self.confirm_page = QWidget() + cp_layout = QVBoxLayout(self.confirm_page) + cp_layout.setContentsMargins(16, 16, 16, 16) + cp_layout.setSpacing(12) + qlabel = QLabel("Do you want to switch views and clear the plot?", parent=self.confirm_page) + cp_buttons = QHBoxLayout() + no_btn = QPushButton("No", parent=self.confirm_page) + yes_btn = QPushButton("Yes", parent=self.confirm_page) + cp_buttons.addStretch(1) + cp_buttons.addWidget(no_btn) + cp_buttons.addWidget(yes_btn) + cp_layout.addWidget(qlabel) + cp_layout.addLayout(cp_buttons) + + # Add pages to the stack + self.stack.addWidget(self.settings_page) # index 0 + self.stack.addWidget(self.waveform_page) # index 1 + self.stack.addWidget(self.confirm_page) # index 2 + + # Wire settings buttons + ok_btn.clicked.connect(self._apply_settings_and_show_waveform) + cancel_btn.clicked.connect(self._show_waveform_without_changes) + + # Prepare result holder for the inline confirmation + self._exit_choice_yes = None + yes_btn.clicked.connect(lambda: self._exit_reply(True)) + no_btn.clicked.connect(lambda: self._exit_reply(False)) + + @SafeSlot() + def on_enter(self) -> None: + # Always start on the settings page when entering + self.stack.setCurrentIndex(0) + + @SafeSlot() + def on_exit(self) -> bool: + # Show inline confirmation page and synchronously wait for a choice + # -> trick to make the choice blocking, however popup would be cleaner solution + self._exit_choice_yes = None + self.stack.setCurrentIndex(2) + loop = QEventLoop() + self._exit_loop = loop + loop.exec_() + + if self._exit_choice_yes: + self.waveform.clear_all() + return True + # Revert to waveform view if user cancelled switching + self.stack.setCurrentIndex(1) + return False + + def _apply_settings_and_show_waveform(self): + dev = self.device_edit.currentText() + sig = self.entry_edit.currentText() + if dev and sig: + self.waveform.plot(y_name=dev, y_entry=sig) + self.stack.setCurrentIndex(1) + + def _show_waveform_without_changes(self): + # Just show waveform page without plotting + self.stack.setCurrentIndex(1) + + def _exit_reply(self, yes: bool): + self._exit_choice_yes = bool(yes) + if hasattr(self, "_exit_loop") and self._exit_loop.isRunning(): + self._exit_loop.quit() From 2a4a11a96f4bfd61dc5b52b9a19d913b7af4d25c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 10 Sep 2025 21:12:50 +0200 Subject: [PATCH 43/45] test(main_app): test extended --- tests/unit_tests/test_main_app.py | 111 ++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/unit_tests/test_main_app.py diff --git a/tests/unit_tests/test_main_app.py b/tests/unit_tests/test_main_app.py new file mode 100644 index 000000000..3d3a42f14 --- /dev/null +++ b/tests/unit_tests/test_main_app.py @@ -0,0 +1,111 @@ +import pytest +from qtpy.QtWidgets import QWidget + +from bec_widgets.applications.main_app import BECMainApp +from bec_widgets.applications.views.view import ViewBase + +from .client_mocks import mocked_client + +ANIM_TEST_DURATION = 60 # ms + + +@pytest.fixture +def viewbase(qtbot): + v = ViewBase(content=QWidget()) + qtbot.addWidget(v) + qtbot.waitExposed(v) + yield v + + +# Spy views for testing enter/exit hooks and veto logic +class SpyView(ViewBase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.enter_calls = 0 + self.exit_calls = 0 + + def on_enter(self) -> None: + self.enter_calls += 1 + + def on_exit(self) -> bool: + self.exit_calls += 1 + return True + + +class SpyVetoView(SpyView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.allow_exit = False + + def on_exit(self) -> bool: + self.exit_calls += 1 + return bool(self.allow_exit) + + +@pytest.fixture +def app_with_spies(qtbot, mocked_client): + app = BECMainApp(client=mocked_client, anim_duration=ANIM_TEST_DURATION, show_examples=False) + qtbot.addWidget(app) + qtbot.waitExposed(app) + + app.add_section("Tests", id="tests") + + v1 = SpyView(id="v1", title="V1") + v2 = SpyView(id="v2", title="V2") + vv = SpyVetoView(id="vv", title="VV") + + app.add_view(icon="widgets", title="View 1", id="v1", widget=v1, mini_text="v1") + app.add_view(icon="widgets", title="View 2", id="v2", widget=v2, mini_text="v2") + app.add_view(icon="widgets", title="Veto View", id="vv", widget=vv, mini_text="vv") + + # Start from dock_area (default) to avoid extra enter/exit counts on spies + assert app.stack.currentIndex() == app._view_index["dock_area"] + return app, v1, v2, vv + + +def test_viewbase_initializes(viewbase): + assert viewbase.on_enter() is None + assert viewbase.on_exit() is True + + +def test_on_enter_and_on_exit_are_called_on_switch(app_with_spies, qtbot): + app, v1, v2, _ = app_with_spies + + app.set_current("v1") + qtbot.wait(10) + assert v1.enter_calls == 1 + + app.set_current("v2") + qtbot.wait(10) + assert v1.exit_calls == 1 + assert v2.enter_calls == 1 + + app.set_current("v1") + qtbot.wait(10) + assert v2.exit_calls == 1 + assert v1.enter_calls == 2 + + +def test_on_exit_veto_prevents_switch_until_allowed(app_with_spies, qtbot): + app, v1, v2, vv = app_with_spies + + # Move to veto view first + app.set_current("vv") + qtbot.wait(10) + assert vv.enter_calls == 1 + + # Attempt to leave veto view -> should veto + app.set_current("v1") + qtbot.wait(10) + assert vv.exit_calls == 1 + # Still on veto view because veto returned False + assert app.stack.currentIndex() == app._view_index["vv"] + + # Allow exit and try again + vv.allow_exit = True + app.set_current("v1") + qtbot.wait(10) + + # Now the switch should have happened, and v1 received on_enter + assert app.stack.currentIndex() == app._view_index["v1"] + assert v1.enter_calls >= 1 From 4fe30183231250bcce55ff3da404ca3712933722 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 11 Sep 2025 15:44:47 +0200 Subject: [PATCH 44/45] fix(colors): accent colors fetching if theme not provided --- bec_widgets/utils/colors.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 34af4b82b..236cd157a 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -1,19 +1,17 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Literal +from typing import Literal import numpy as np import pyqtgraph as pg from bec_qthemes import apply_theme as apply_theme_global +from bec_qthemes._theme import AccentColors from pydantic_core import PydanticCustomError from qtpy.QtCore import QEvent, QEventLoop from qtpy.QtGui import QColor from qtpy.QtWidgets import QApplication -if TYPE_CHECKING: # pragma: no cover - from bec_qthemes._main import AccentColors - def get_theme_name(): if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"): @@ -29,13 +27,14 @@ def get_theme_palette(): return palette -def get_accent_colors() -> AccentColors | None: +def get_accent_colors() -> AccentColors: """ Get the accent colors for the current theme. These colors are extensions of the color palette and are used to highlight specific elements in the UI. """ if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"): - return None + accent_colors = AccentColors() + return accent_colors return QApplication.instance().theme.accent_colors From 9371d4a16e2fdc9294961bfcff5a615c70c51d04 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 23 Sep 2025 10:47:00 +0200 Subject: [PATCH 45/45] build: allow pyside 6.9.2 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fdae48c0c..5673dbae8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,13 +20,13 @@ dependencies = [ "isort~=5.13, >=5.13.2", # needed for bw-generate-cli "pydantic~=2.0", "pyqtgraph~=0.13", - "PySide6==6.9.0", + "PySide6~=6.9.0, !=6.9.1", # avoid 6.9.1 as it is incompatible with pyqtgraph "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", "qtmonaco~=0.5", "thefuzz~=0.22", "darkdetect~=0.8", - "PySide6-QtAds==4.4.0", + "PySide6-QtAds~=4.4.0", ]