From bf123550e1b597d1638c15fb5b77de2677a305e9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 7 Aug 2025 16:36:53 +0200 Subject: [PATCH 001/161] build: PySide6-QtAds dependency added --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 161c5d194..7402bd134 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 e4fb126fad8f1d78991ab7f2ea9de8863491e701 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 5 Aug 2025 16:50:14 +0200 Subject: [PATCH 002/161] 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 48a29fd1c..5c82d23d1 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 5a49c41a52f3547345eb175d05667bf555595e43 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 21:37:34 +0200 Subject: [PATCH 003/161] 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 5c82d23d1..c9ac715ba 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 8f359f8d372545189f1307a48a9967d7f3ec4715 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 19 Aug 2025 11:28:52 +0200 Subject: [PATCH 004/161] 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 c9ac715ba..b670d03a7 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 00c0232833046b367ac38ff3a1377a9d0101abf1 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 20:24:40 +0200 Subject: [PATCH 005/161] 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 faa0aa659d6e8f09ba0ac4661e791d57e601f0d0 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 7 Aug 2025 16:05:12 +0200 Subject: [PATCH 006/161] 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 77295f13f7658532bc636863e6d9d75572f0e51f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 19 Aug 2025 11:51:25 +0200 Subject: [PATCH 007/161] 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 5ecdaf412104f086c9baca01f0bd7098bfe2951d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 20:25:01 +0200 Subject: [PATCH 008/161] 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 a58fb3fa5349be1a75ea108930ce9b8568e51d52 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 6 Aug 2025 20:29:07 +0200 Subject: [PATCH 009/161] 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 6643dc2b57557b03c18df62c56301645a653d1d5 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 7 Aug 2025 16:05:43 +0200 Subject: [PATCH 010/161] 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 84eb99ab2..a64cfd957 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]": @@ -5219,6 +5529,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""" @@ -5258,3 +5580,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 241eec4f4..2d4ce9273 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 cde4c5d0a..77c31fbd5 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 fb71c8b1a..1012229b3 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -67,6 +67,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", @@ -109,7 +113,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 eeb413070..6385e3875 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 @@ -92,6 +92,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 24f54fc25262e460b78a7bf2fee131635e1f777d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 13 Aug 2025 11:22:22 +0200 Subject: [PATCH 011/161] 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 cbcf23965b5a9df6ba5854277eb55fddb7770bfa Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 5 Aug 2025 15:58:29 +0200 Subject: [PATCH 012/161] 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 a64cfd957..2bb1000f4 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 6c80dd130..88a7dc44d 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, @@ -119,14 +121,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 4e96d6c53c5ab645bfd880eef11c2d8ce2d30ae8 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 14 Aug 2025 15:06:07 +0200 Subject: [PATCH 013/161] 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 6b1b7a6b5678853167ecb77e156192326dfda9cf Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 15 Aug 2025 15:24:15 +0200 Subject: [PATCH 014/161] 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 9b9b8ab718503d5eddf01804f6b058210ac29675 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 18 Aug 2025 23:03:51 +0200 Subject: [PATCH 015/161] 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 2bb1000f4..41b9f0154 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 d4ea9f29924817e4f024dbfa8ff2cbb695812c87 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 18 Aug 2025 23:06:11 +0200 Subject: [PATCH 016/161] 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 66f01dc9a6b5d9b6bc2539f6e63ebbfca235afb6 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 19 Aug 2025 11:20:48 +0200 Subject: [PATCH 017/161] 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 63b7f92f224bdb1ede722a70a9d2e06bf6450bf2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 21 Aug 2025 18:14:02 +0200 Subject: [PATCH 018/161] 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 7402bd134..0743ae9e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ dependencies = [ "bec_ipython_client~=3.70", # needed for jupyter console "bec_lib~=3.70", - "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 138fd1bea79d89acb01ebbde1af74a61a8fab34a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 19 Aug 2025 10:50:56 +0200 Subject: [PATCH 019/161] 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 88a7dc44d..b5e925633 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 @@ -168,6 +169,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 77c31fbd5..e8947350d 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 1012229b3..7c156accb 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -26,7 +26,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 @@ -2393,7 +2393,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 30e53ad23..392c0a6dc 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 125c94cbbe33cea5ae9f726e913a01e1449458a7 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 13:12:15 +0200 Subject: [PATCH 020/161] 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 dcdf5a4a54f9a3acd90fab61e53ae45c805ac371 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 13:30:13 +0200 Subject: [PATCH 021/161] 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 b6f8866667c32194da9481a463fe2aae4ff88376 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 14:35:23 +0200 Subject: [PATCH 022/161] 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 43d49276ce08ff250e624069d8b974e14e782c24 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 14:35:43 +0200 Subject: [PATCH 023/161] 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 a5951cf31..973d3d057 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.QtGui import QValidator @@ -97,6 +99,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 @@ -194,7 +197,16 @@ def _init_actions(self): # If device row, add "Add DAP" button if self.source in ("device", "history"): - 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 03fe3a83c4fe04e10ec57bad3839729c11c70055 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 16:59:11 +0200 Subject: [PATCH 024/161] 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 973d3d057..f8b093d28 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,8 +3,6 @@ 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.QtGui import QValidator @@ -36,6 +34,7 @@ def validate(self, input_str: str, pos: int): from qtpy.QtWidgets import ( + QApplication, QComboBox, QHBoxLayout, QHeaderView, From daeec7e0c9c3e73a20190e410beb32a44904bbf0 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 25 Aug 2025 17:11:20 +0200 Subject: [PATCH 025/161] 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 3e23ba9aa27d527a24ee732669b82a58a8d256c8 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 25 Aug 2025 18:03:49 +0200 Subject: [PATCH 026/161] 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 b2d6b8dac0af9e78e1b3ba84fffc32b38627772e Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 25 Aug 2025 18:07:13 +0200 Subject: [PATCH 027/161] 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 b02994558ba849b5a0c5dd3d654b9ad3f3bad1b8 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 25 Aug 2025 19:37:07 +0200 Subject: [PATCH 028/161] build: add missing darkdetect dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0743ae9e1..f0699355c 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 d21464f98fb3b1473eb685a08ada27341c6c022c Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 26 Aug 2025 08:47:55 +0200 Subject: [PATCH 029/161] 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 zcmXAPc_38n_xPQ`*bR|=DF&6XMb^QmkVYnz?1quDZ&^pS!Hd+JDY8US*6d?vh{0Q8 z?6QYJ_HFFhf7AE(-@Rvh&OPTj=Q+{_aA4SoQqA!i|5q>KdwIz)_Vyk{+zP>Sd+BzPr0+O z+0fV8g!5CoAOM(>3Ek0w0&2n>hXe9J*l^7{b#aEVErp|FsVwaL60+&6^#ID@agewv z&tZ3dU=B*YgtN4ms6h?F(1sk$nmO(Rr-!T3;ktpYNU?TZDKU=qH{ES1<87BwCYRZ} z41k^CPg=r6o$z_r!7jd1=+_+Wna$>Zf)Vciw5Kuy<&%=ng++y)nY{`2{+jPDu@@1UDsxI#H?LPsmn0;@x_8Q8@l-xe z8fo@^VEM%9@)+q3Q83$cE01}xu*_KbRDQEE(S6)cB3P}Y7*6KR(?|ySkE)7R7GJ+^ZV;TTiGju3;3f$Tn{Pgv#4cne zH@c*S8e_2|MGm~vI}gJU7xor(pvKpGC{wf3uiGP%u`qd``LdIAB`dQ7?V{?~#vki%Nfq?e=QmJ; z9ot}0_ z`<(bkY;D0z6!zlhd(XBPw0#(bsSq%BXwjErF}1$4jHop6kgctlp87au>1*}OFaKTM zP7}+zS$Sl_<(TO+BIcZ)x^mlf)Z5V`ec*c_Ntm8$J>-v0o-et;Ut~M{Rq!6ZJ^Mh~ zNFON68Zg|gNa7zZp*P_odAYX0$*avhSt+ufhJ5<960$Kf7r|It6gu->eo_0^o}p2{ zueW^e5Jgt=&DJj>T?ET!Q`mqkGa;`sK`6LDiJIL@&p4b0en;^@p z_^viBX8lf^JQL;MB4M`@#(Jv1R`IV6asZEq3b5h1NhXCv|IH*j?2H+WtCrS zNwDXEblK#(3TqED#fe3`4CNm%%ZgWB*vmA-fA7ZuVbxQe zaq=g#jklljGEt^Sto_YfLP9M5w3gMzKXT+!2c4ul#cFoUbj-$WXy;N^YqH}$0AZd7 z#dd#02Y*V?26hT$dQ~mP?7n7iCD&3eEx{q*jd^q{HFv0Gls2CaIxfpcohum1&M7xkqy-o1zgnp9`6 zk+G5ov(voE^>l&+qs23hzdiRx%q*mPWM zL?$n`m$rEAs<}ejV&`O1!dkWe6C$0ej5xh%q6IR)0<%|29y&2pVK$FU={=Xj$7009 zQE7$OjPp&X-{7kQ=F5z5abyA>k0-0-3@$%1b=4pH%un~n@2K&Eq9sqXx)7mmZfmXr zUIZr(F8h8qr}sr>!?7GrMxuCwT9($AofUF|oGU~#!S0@%^6t; z!$VG(A*FC<48l(T?6fawTDVvaXX-51DHC0Y{!|}%mOLJPpw{Df`e~)t zv}X=Qz)S`eJDrn_|A8_&&(}{ykA|Pvd^%@r0e^-Ixao$TvkKw_tGm(<>S_57}7^F>UHPixd0&&YVd=0 z+f8%JWONNbIK9t~EE3*`h zm6upl%C5xUgoq_X`72nz+wdM9`qtyy|G=L?^MPM&G;d09ziY*4vR~|tUcP0wF4&q; zMxaJl*3tn*1t3YbqaA_T|A{(x^eaPPm*r zDxsy=kXZhT_g=n2XoDNkz(5;tNrOnfpCmYUw-k3IgX`dA4CQwC&4WLWuj6c!{Rf?U zo3qqYO~;IYl=-Vyo^}zZZEtJQ8MAe)2wnNE4GZiMD;uDJ+cS z0ux@a(|Zppr^q%#JOSuR{@m*f|J?5|GYlO_n8#K!)^p)Qo&=@fW>UvxW|j)x&K5BFyvKMpC3I!5;d50Mb6Xc`qeba+=;aP4~s!6a&GikJ@YE{9tzRHQ1X?f7tJ`M z3Y3hSl#4;O^bD5ygUFk_cagQ%y{?;L85-egY@#d`MvE9O8ForbailJmNH!J#V32*6;Bj4Ylx3l<@-3KZQrXhcWLAf_;EajQpnlUVGA*F z=A{2#&UX57#J12v`>7>FNmW}rM_%C-EpZR3TihpBluq|A>}<9DCxej-^>@PZ7b~SM zf$JthI2yt#KRG}#R+h<(Ba-!hp?`LKmAU2AVUC`Fmj;_?AcPSwG3pE0?TawHehw(r z3oG^2ye@@hF;VhdBAX=8%_PmImb`wDRl`~Kfs6Yh^M^};VA8Mc#=ayy8<36ZQ{S3H z#IL=EB^j{zMMTKBuA4;KB!oKOS2XLqBVchmEk?EJC#|Z?-sVZ|OK{=a70E4ln@E~P zBeV10^b4;k&^Bdb<1_mLK05Ecn?Gw%rmvG(2q_bwFl6Qf_0ukU492!(s+7}p&fRo{o@Bf2;9<3?q5F*)H z(pOFaH75*NR`f#mI^>NqUwKSGbjRW05ux&t>3QW$j)#C8xj*!Max-azujMq_i?;>g zH0$i1_I+8Nb4GlWogFSrYWd2V-26YI`Mx~eoqw(lmM>eJf%|PuENIe=0LE_b(;xg~ zJ523>XRBRbLXLE*LGI4p8H5oAD1M-qiCvfk35RU>9Gwzz}QIxe*e3ZY(q|uzIx-M%l*W+W;dw+#ca zU77?Y27FT(eDjMEw-g5jvul;Hi1aawXW)#5A>}riZ8zo#2LOko@&2YxrF6s{1}G^{ z@mok_zlb;V8S#@^tgoSz=FI`a z@>DHc)T%ZM?O9bc?&jBPC)&7%W%Ie2-hyozT=T%Qt7d}%4&}U|`fFEkz2Rx|1AG+k z#g;3$_m^-JHS7yssbLJ9_g{Y71qtVr(Kzn9NL$bgv3D1NgpHaj&e^>6saQ7Aj5kfA zXF?(W(NR|B`X|{Mc}V-2ps-0!cfK@p@aUNmDSPdf5(=0IhhSf#o%5I}m>n3|=eQRL z9F2BN14;|G_6+`VTtHllV!Wyttc;tu=ey`20;EJf9;)$JyR~BjFiuDzw~t@_1Dbv7 zcN=Qd(8ng){eUK-*?})@nvptx=Bu#4gR_oIFm32VaFJ;C$XhqC+NdTJ032!Um0chO z5_e>K9@<%yh2nV-6q1_z~kvwSp?ch4Cli+MvEDXg!BO@9vRp#E+L|DdIAMJt& zr%t#<2B>j6>EuIRi=}=Zn3WQ=D1+fsqWS`i?03+>q9Cm*=0zaTT_h-|7{rXZmi87a zj$3_pS_z>Q!>pp8Tj-KUPpZJ8nGY>MbVfDc05;*;V9+n+*ED=Vj!T+wkK;qv+b<`+ z{*S%0O!*S1@s84k=>aDW+`0~3BMkdS0}?+rDZ8|F_z&|tK%PZ3$HReD!&X&kh~5xa zkB<;C=f1t>mTK$fSX0Mu;r(O z_%kibs6uQYKxcGiX=UyE*@a9Y8Om1J3sD&E(DP|u++PMR;koA^z;-~#)L>4HH&ng& zD$YPBr3pwB(yBQdxtfpa3hpl2LVdls9 zv}BuJ_WP$4LS4Gj%JTOed(*m)%W%^ilZz~*7sPNZM)w7Lmhj^u%?08(Fu3=1DGEOJ zO^|hG!%rZ`3kZxqofz$l2;jQ_U6=S50`qMJPBbmCP-y5WQzj1v;S_R6H5h`8rGSb! zHB?$4Kh#1|5fJQ%-jYlrZmhaN=+Xcm#%xC10%&hw@HiKsgs$CcUz=jFfL~3nF~4pBY8N8SK^-=3g0XH~ zW(0A^kpWE<>29MauFjbu-t74*b($6;n*Z{(tPp0m4Dx6fE649@g*6fdbRLLN$X3yJ;VAw_-eQcu$*>Q`>hJ{37L9Uq6F) z-N_yV!1B#~n)AyZ=-lR`c=(CyFCVe|jo}IG@9)dsd6fayt^dXU<7fw1-~b4Yfo@bl zfuw;wyUj~#ngL|$@i(QNe_8i6odRBSZ-pdi+7f_J60n-Ze)WPG31F>Uh)ev-m--I_ zK~D6?TLKt@n+K0F(~}IbmkemW26!*wK)efms`o}}KYbMfk{`2b{N*HM0sDun6c2Cay6F9rrrj~HIS|j9$>8lLL)q!6toi>okfL0n8+DSy)mG9>GbTXLfa7Xe#cGCc z_1D#dJ<}cIM(8--KLhd$w%E$m&hIg1woaW{0UYJ#nD=@D_z4+|bsw9}GTiR4mJoevarvo+MbZ5N82i1+>DQkthk z{(35aE4kMm*imBAu*$|;wJ3TM6v3oMtvgZ*v}!NlGid5d!Kx%SpbW57=Sx zjI0LY)o|Uj05+47W6WUoXh3uNIFE}2D6xoEUL#~(2pO*cLoB>GY2UB6QG!FYfm2h` z`Nabc*y@hGrJ_KN{W|sx-i>m#nrpY{8lz4Ze?(7j!pS}1<26kp8I?_s#Zy3T9P^j=0a33=WmelGS_K#-20X&K(bV6N@@}3$FQ`_DPu=3;M1r7 zVBoJDFgPSVe$d^%C^rj1R~>2Zjl4(^UXmM);J%0MqYclSfph)BbaY)l zzz4Q(D*vs-t~5~i)%Y)Qkme!{oT)fT6`hb{`S^$u97t*f2mpj@GtEeIJIu7S)L};A zz|HOJ0)dP?a}SG&g^poJK$8c781(a7Wam0Mq!;gPZ|LJ~0eap3#W}7m2L7*(b)BD| z5+wKrtAUeudKfQ10H!d~x=CfY8<)16f&96LTDca0F|%ko(v@BrN>81)jCO?K^6By2 zpK4;%w0KBqZvorSpg22-ci+E1Ydp|yx53yuX!FHfr8pU;Wi2a4G56F*N%{&T-AKl~ zQ>P483Y^>o*7n&vQx-tXXzF|$2D#IOvzkJ@#Qx;Wm4suj?&G) zb-+za5D-D`zIEJtB86+vaz4ES$oq+ng)dC_4w9ncglQqN!6cd>hoK|^J)1my0JZx} z$pk_`JL4E>R89E@y0lG>XgyO(Wc&*hQx-YyH4>ybpWZ9G@LJ|+I zluUSC##p+3i|$_r=yysvb2F6ko4T286YQLB1)&RZSs`kjXr|vyd<6I|r|5%%6vw0G zWNNv=HsP?m&a%3N6t7nlgzi;dQpx@_LOwZIeN7H0FBBQ|k`AYKwR!et^E5wmymH|~ zfbJitZhkYZz@uG)b1kYL!Y_I5)ty{Zhq4^w{miI# z2_s>WW%9BCxX$LRkY*3&&YkT~Ja=_2aAs2a%`MKhV}OnEuC_3k0GnP;Y0;#V_e^kK z!-kK^X<#*E!YA=*8g~Tcf#V^$tlL64b1|E>o*1XN!3?Ph+uS_-W{})mtb{9rM;gt{T!_!WQi6MHN ze~kjEeD;|5^hX_A*RlduBX7eF4~$(V%x>@!)LWutkOOd~!}lqT4nE6UYOct_)xuH( z{S&~=EU-`$f5XBBX!r~o9jT7mKx4pO&!G&y>f&&)-y5{WwvozV|&3@<;CVRF1c(u{* z=A68erOyW!0@tMSOr0+-aHZ_!vt=+By?0t(kT$Wm2r>2olH9M1A{?#_FgR9IgJpVO z?Vi+7bg@U^zsIz+I!q3Y=>`2VbtrfN8CDI*?)yXTN}fj7S8$bx3u3d0rBUNWh~6mc zF8f#z!lqQVakaH&Yr!{RJVYuP5Iy?U8$K>-rIZz+dWAfA7#)>D>#hU= zcJT{eR*5bv^R`@^$m#wib%zC|eg>+!zMq1`(asYI!BpQJ^+s<6@@>hhoccg`=sx1) zcx_Cy_3aX1a(Hrrm=3weF_ZfI#T8sxfaJ$}*ZEUtmdvNQURjv~&}^s3(j20Lo}|Ki zVril4?xX&||Bg%K{!%WpLGlKaQGI*qdGDJfro3K*PW+D-4(+M>*`g$QFCk&PfcGx> zp_w2lCAI|>$PbT-NcnKjexxkhF1ca=5ao!H*NBoCRL9vB6<$x3^G1k@oL0QsgHZbf zgiofv6c&o6+yf9c4SNlP`*Qu=?S;2vHGsv=Z62F|cTelz3l;z82(Ig;P>DGea8v=k=m*s? z<3_<7s;!YVJb$x1gJs+DJt{NbCzn>kx1Xtmrdq{Ygs}Z>A25N`a zd|$Lfa-3%C9#u#Qu~N$XsqJ;a^6Kvd6LCBJTXSEu!b0xY4Lb&d5Xw>vS|O;3kg#hn zAw-7)$6tPaKmGo@=COwwOSvBZ^6I#DR7uojE02k=0LF*eS#PqgbfrJ}SR+U>m)1Y% zZh`pyFXu?T`sUz+uFsYVOya4ZA9SRXGj2Pk=(I)kjOB zIp<02vd!*Y3v^g#j;0?JD~N8@T2KsdCpu?w@c=7EJeTM7d}!05Zm2*)brQq=L;Us1@E zlyImfZGqS0J3Ifhr^pDOA9XUYaUnJ<|<#{{3@j7#g(3CeKTC)o|#dQ0)%T zbVItx#dQ@ky2KvUR>i(d9$weM#j>3ZdYGbrp+`@@i=uzA7aI&JXP%!1zHgF3RX`um zQz_d$F?s7@!HiEuNY4jqMO1n6TpR~r4O|ZZ=z$c&0i#$#Y58R}TX6Ca-`z7+xlh6y z2mb9`$25HAQ||!FS$iAFVm-?YIWnvgp4hZzWCKE|iR#FCt!%`YW@z(0*C%y95Q=U4 zeC6d_e~(fyw*w%?5sQ$IZ+D=6qYX#cS@{rl!%u|q?FgujVjG8^AgL{Apxr<%&%xRF zAr1Y`23;d0LYu?r*iS;%^cz7TZja0+RPuX!j~;Wgr6NrNwIVz;k0@?Glp6Ss(}Vew zE>=lb$L{}}tjmJ4I+~l#FD~yGHoSGT+n3u1n#MAAO_QHKmh1{%3J%hF86YA4_GI(C zc*k>EX&f3NPh=L=E+D;2%hOzC=4}d%cw)|6EN8YeU8gp5+^<-pFHxTg_qB1V}h(!%%HKIh~j}$IbtMi(CaOAO*;paE>8$1f*0WmlrV|(TyPjs$=V#D5L(`qG~ z3nUXWNA-(Rv;b8>h3-`rK%Unb0>*al=b!1iZ-l^1y%=hlD3*{;YZh+2b#Bi}2}K)( z7wdTYpu8dK9LjmWKfZqduQ=^DO#ba<|w{y#Y_N` z0CP#Z<`Y9BuwF3}?6lw3j?oeisuB2lt;jkz{OA?sl%6UDU39;2eFzl$kj~;IU>n4i z5@^;I9~(6PDbQkXq5g5OMGP?YgC(z+qjrS2pUXU@;BPuT=Am5`+enB4vvQL(1*W&k zg4KGv+wmqQBlE_0Z6$7CwCJhHRi~zHc*K)RPwVjlTb{JM0@|MmlAF^|CuEw{UaKJ! zWm0tWRO@ojuXjy%Ph-o8g)8k-5EcfEKRtEI`1pB(TI(+nm0QBQt**?JP{iAG;zq`g z+8FG=LiZ|Q<E7?>k+C;PhSF{6>N-7`w?jdv^Kk z$9b5APA)Rg260|A_mWtcCKr@0cAx-hizb`W+XG z4vzO4CqnFY&S?vKpH7F*tzqcR`)T!fCVnQ2En-{vYUCQ->S-9 zMkLkJKEZU&f&9x})Hf$~mK5qJx^*}7%LzG&SPG4;$-L5E@-F|>$=_amEFoB~?Yj2D zb!Ey`aHXG-pi_e48h9TzIzZ89h3y#so=NL^X1Im+ForX0|rwn<(p z>$f(qogup|;IGh!MF=n5o}hp3ySjF5?s1?A7j^6~0`7dQRk>ce-Dm6p=kg zcZqVW2Ug@E$M-WY)5|J|fx21~a@>9_Ki*u^Q>UsXohO;??U~!@oSn^5qc(q@97O4E ymx-@7^jXz`UtJ)%;~MGr4d$5u;i=7Y(5;!m=vWQ2eA+K?08;;+Ug<4Ny&U@d_wc|!<5dXm3xSU}cWG58?ZR-ZM-Z#NGq+rer}GEx zn1ZR~cVQ&h;myr3A%I@@=d~OFB)@UpfUrY|qr%ZLQH$GIBF36qx|_S17|qE2ROAe8 zM{b9xeizvm=N28!i`x%PzxS6oN*JcO5qIZR{MAfC{u#Jere=bmKE%u4)$h88UND5F zKZBoTtrAB=Ouh=ascu@(49}49CI>t*U#MG~ZDH9W{ih)9E|I@{m;@rLs*UHj%0@!~ zOLIGlj5oFaxqAR}6ecW6w_%UkEIR#6KGxT{OdLgb{0d3P70=bafw4bmZ6Hn&gQg#^ z^kWhHM&m;cy+0&Sfm!ZYW~Qm;C~y2-J-u|dYoO})ugsPUN5Ey5jAda;koNkx*8X{# z-P5aw5=%_J#UXEOn+o@CdW6ufzn`f=-Yr`x3$*Vt+MN%zKcdx4!`dGljn4YH9%I`k z{jpIH%lwOF1`p5R=R#<=vN+1T2$af{xaBRKM}()W^U_oB_m+01$A5 z^gvL{GCutC^ptcGaP01`=xe`>l?TXmRX;S4h`MiXpkcZ&vy;yu4%!PgGPTxkT>2LW$CL~S;ASzNFg(fq3x?7 zNbB0>GzPlEq=WNVT+$I(2d`%V}Y=3eb&|Csm=jsGH|u5bSAFwlMl_k-}co{eD4L3 z(GeON=`h)S$CQt)FWmCKv{h5Ru?=$JPsrhrZyWuqC?q$hb*D1E(k+*a9DS3 z^yON(0%t^wqmtaKFo#si#1R^$e$sM6iz3Y^VMtw_Tk+NU4x1Tfl(&F13I?fr<;LV7 zI~NUU!Rrs)LscMk>G_2LC9Q($F}Z0WwZn^A%hFHq8cUZN_w%v2;STdjlNw{~?f&Pj zJTaP=o7IiPW;20o2UZmU>*f<<|Cd_A`W~3gDd=hCh~^(5dHz$|W3^0`t-jvxub7?y zMAft8uDiNw3aeNSbGt0w#i(p9=Q#t#h*ujV{I`SP75`Ll~;ZlOHc2O^%PR8pdkn7J(D$ak%lRb zJe^C|qy3-f-mFCpSpu}L79ZV*(;uyj@Y4>^)jnX>$PR>z-A6bNrsLH*Qj;HaEsTIw ziIl(lZFQT&;Z$CHUsSTMnGdq{uT*nKVUbm{V0>@y-}SF5g*@IC{OowF6kb}HU#n`2 z_}oqtN>_wzk$gP&YhON~iq)fczrzGt0;4m7g`JL(@`SfW4F2u~TVV9p724D5yzDq1 z!EW91!+dNv>`e7n>g@Qq$(TU(aJp{yvrmouY?sm`8P&_A&0()>luXAXes-~$XBS*z z;ki#1rY27Z3DE}yEKr+0*1;BIB7(kB^|PdViGl0J+2I`;XbIf1R}P(7=FGWb?QX$)Qd8*?WKY#7xL@dnm8qs z`b>}C>yiE$kECR9Gs)s~&XSJ7^*=#H6|Rjx%vjV;&Lb_4{+jEyv#u=v8_5EFdB|cB z>WN$NaxSdY{#2L)y;-Nidn36s93r6H_NObyh@s_o{+g%ILArMeKARK0vvq4(rsUhg zxJ2a{VZB4-@T2K{^3c%!St){(kdxHF7*MRS_0X*BiytdLV%0ryMb0Qs>xG!9dx}xX z=r>6jd?1sMlaq5g{VgjD5Uvc!XpqcyH|f@FEuHCY^c~KIg)W=-dbssJAIon#5o_*N z=57)!MbAsU+Xv znHSqAd)eV>=k>zaf!uoT1(c%L_lAO=FXxdIdj|*87Kh{=cJtTY$|Q5ORfDV|>XQbF z3SKF8pl+KBoYZNfN%bk-!C`Uas6>0*>~T_$>5HPr4HQoYvDF(%RV2FZZ+Vh=^^t#r zZvuL$JFoQeV&C?g0MSuVW*mLiygA}p zbe#o>G|b7{B*x#)&Z>gMpMK8RKRkdMlB?@gXe`r|rrTah$)CU21w;CvV>8WU+O7OF zNSXOMIni6Lf==;8#4(E5ysCcAxE8_rfh|uUtBTRy{jdR?nj%}!qtq4sudjCjUrFN% z^!@y15Voa84JFbgO6YSj}Lli!)r zniADm;AfcZ_r^RJAJWSj;>sihu|H284A_B*Kfz1LApCBlimA_l(wWe<01P=foX7u7 z(WT|&5wtCqCEc#U`YNQ%TF>HnXX8aj zNjjtPZC-pWRR8AtQtHqr47CME@5I)E2VN6YGO)9?RkwATsLmZ4UsRO)tjbIKjuyrgDA-KS zt&|{XS*A!>1mI#roDE4mT^mz3m};n;+!MLibAMZ1;}PHtO*Z6F(d+Q`A|y!g7vK%- zL8qm5B|qmP9}qMoj%MDUcuR(}m%pi)+d-`@Q??%{bEWq^_CEAF!2TfE3-M&m>K`=k ze7Q5A)$t`sz}f&#KP-8_rqoALlu`HTG-QY{yuMYwH1JcM0>zMtThH$s=J*Ud*EK_* zrB!KyAwxMjhVUivND*(Y)K~Wd0$hPCO$Lu%Ru?_|6_Xx>Weyeha6)LUkPi(vzwtQF+#CN zEBq7Q^ZVG#-FtJx?B(s53ApU#QXsjz+Cud`Pk(OiiGz(cKTf{vU#(BoBGq5yQh!j* zMfr26|6K)NmEwcrHaUsJQFA8`&;(fz64+IKVvYJYH-Kc1>rZI(opYvvgTeb~8QPLP z;{zZ;)zp+CB^lcL5#syzkT&1%Z*^W@gJx?wr_}%;rp; zTfQjVJYj=)I5pzNdmdVw#zP7AnnPm%^C=&2{2l$Sw_HolTTh>^UkxeR^(1Ln3xB>c zZP`!uhQ8lk|D4uv06OcSJH}ML^Pw3HiFDk7@*)JoJqt{3$MZwAZ|Z)b+5oWli)1_* zKkBwZ3@|!C%Q!|-qm_W+P}&Z??EUG7Q`vu;Iq|AJAC4WE`?AcBHHkZNzfO`q)!LP| zgaUXU;W6Q;K9JF4bqDE;fmFxn7=_#B^1@+CB$nm~@zoZ$kV+f%=LMmNJ0GUkfw|2F zzvz-3P+zgucKMgNZhv3eb8!5o!^SjkAllTvKjmXqwlMqW#MxhJXY|FV?@eSJhk*y? z{hO^t$p3;Cv%NLSgXW#uEu1(r(Na=JEr2~&{NI0t?-o?YTYrL*(ik(sJ zS~?NyVs5h3GY*j+CEoaZ2zO3`nSHuVmWW$mbtAZ9=9f!fTN99aR}QWp?n6>>lQ z-b8mS#CJ!6?SBi%yf@JV`>!GY{|)Kk9WW1A_asS~dAxKf|F*Ss2GC0SbBKMaH4b;G2j6MaP z2|19tgOw$FpPT>|k!v9#$f*0``KIt8F*-JACypCOGC#O@ngUED19n}bU${_oh1}aO zJB&r=K!pff4KjG?evB^?NyXAV^Y()T1POB0;iD-9Gnuz(K*~!F`=v$~%`J~$^?iby zuUcoANj)^?(>x5(mzUJXw$s%hzOV!I%f4nmy44`k0cVukjbQpplE!*Wm>lc6SYUQB z36gzK@TD?4wpNVJu+xxw2wZ){S;wo8J1lrZR4#0W?qJph79NMHKR&WX_va3r;j$uG ze)7C{g~yPR37Oq%nZgPUOB8A|3hiESrMp(ZXD>|dwJdkbm$}o=l=2P^35lElX!8je&w5JIGBrvX=fi z^}7rmm=_f+A_hpT-Tw^J&e(4cf!Um>k$OMVZY;<6t~4n}vjku^#|6Ei}=l4l$RoeB!#8lF?{cqK?%Y^>K+^` ziA{7o{9{>+4g@?-epl~3sqjGqa)byOu>#}<)WjC*gkgb5kC!R4j-qwo;A*QljMzfV zImp)dd|@)eOoEQNk(mlQ+b|1F>)OUIDcZGiUkCxfJSq5X0?^oyX?dO{1H{yBuxgn|ZE z!9n0CDU-QV&+hOzAAep|&)UI?^Dw&a9)Ft6j5#YMq*k#s8kBGzB8b4)%sKv)9s1xNeF zVyNMh6Qt-ZgGGS_N9<6HB1!n@KHQ~mz>;Mg1`>I=I{$@+F|6*XfrWW+F~2nc{#Bh+ z*nABoH~#0$(6tLcri9AD{;1u^z{Q*p`-5q2)6LM(R4-Tjt!-^(Byn1g{GO zzF()XS$7&-c^tuGXOar-q}uTIox&1-CxpKgYXnlgSepCT+;VZ;SV8t=Qpr3>6TKgI z>DRpbup2}GN8&%%T!@%#uBnDr!Qm+WYLV0L9U$f{x4z&=hfG82Cc^v$*C_t=VV~OQ zP;s_~>e(Nx4q#`!)eE$yPi{B_-CG_5`AX#KYY9O<~J$r~9u@2WMxSwvRs6K7in1GlH%?dv~{qW9#=U*?Vsq;Un3 z@eI8yJ?QyJSrRdUr)#oPN@#MG{~0g_BvE-|Pnv7ANQt?1TiyORFvv3d z&owK>69RyO@;#y4D*b$Rp4kgaf4YW{WZO^Ve3*ynr`1t9BSI(ORJWNHjf5 zcd2V=c_H#XxF;ge;N(+_fTn=pLT2eqyU}e{GvqlQgZaGqC_h4$WMEOXOPU6%x6w%yG6NSx+sdLeqOwZ6OYLge1=O;?7OlwU4QCiMeaeQcSD6_5Dm((wXIROPH z`4ezB-rScq>uo(F{b)Iv*1oE@%}O!0*1~I-cK%do@qpXN0k0_DpW-E=m-U3EKVQ%K z-1euXpnN_;0`7Ym%Os9QM?V9d=VsJISb$NrV*T;8Kca97zxOt7X1`i^1Yq7$u5>5n zWCBm=az8e-SNeEbwp~G+T4w%(j5SCf^0$0aoJMGqG-Ni82iN1O_eHOLSt0>p4PHL| zETL5|Y^!83+xWf98yR2FlaeyK?r9`X-}jdoog7hS<&u*AF|z!GslA!Yq8=3!3UIb9qbIe;ByzmcyXUUzNI ztlc0YRe(U>enI3B$w(^l_7pUrG7v}!Ij4n)jlm?z*1lX#sJ;>v zte6Gf({0bemb_V-y1{ux$^~|R{M&c~`Cb_A+{GZ$#%pV9 z@3*M)5w(v=61}a%KN*2s0U$cjNu6C@dRp$CQRSBwF3aZYCYpCfdT9Favqg-bA#0v@ zd5SXVnXlCSOzjWiKlCmIqE+^>A6CfZo9Du86sI*TweWNo5F#-TQ5=wf+o&3>v@|Gq zRFwik@&`@r6%!ki-*6r4Mcbm+eMk2%xnP0#cUVi1mfual)-3z%(XIpWKUZwcFGEws zb*+)xR2~&UTw)_#?^T2f*n0Am2Ndc=LXXl_c&U#BT))tJdNL>`rKh!9%^DK`#EX2T zDo4+?7W)Wd=;|5y4}>CB+qz5L5EauP56|t6gXF~S&&B#(C%363mArxCdRfi<82Wbi zS(9-wA4&{h=XP1+24qz)r)%n&6r)$NJwQsXG518#bjw9sR@G_oweRbZ$&zJ7MZKY) zBte|ZgwKJHAW6;0_m*@*K%Kt-)w#(+wo!|049oXr^0QIFjzjCNJ_$T#8n&01MxS|) zUwxp=SQ`Z{P2V8*>?zstMt!gvgsVaYJzDUKbZ z#APPu=0EIFGGrheuN6b2Kief#>4+znl#bUTzEY9aeqANKaWL(>fx4eEW8>kDyv;A+ zGYwN@H;ygUgC)d9Rq4F<&Y0{a;6|r)4G+-CZhZku5dGQTsf~fo-Wx*1jKOTohYsGr zwZZf!BUetuy03QUEbDjqt>Z;kMCh>$c(5~i))|}AvuxYNPa+JS2ZRZM|6F(tk0aj9 z(AAv|vv8|ab9t8o2sznhrZ!TH2t>m@pfjc6PICqb&^G_o?GH!InqgZi%a-wpD+v17 z*CP+Jk^S+EoL?2(TzvsKasv#_FEyoE3`l0MVGGu&xhWOw8N692-J@Ip&4j=F$HS_X-wkgtxAumiEBfyFyP4rn10t7bviZ>|O`cZ@~3sdvqqJvZCbyb;JJZ#A^EE^X{a z_7}eQ3v?mfN#)4b^GCX10sp+%x5kTL8#s$&t(Vv!+U-lo>i5@#6>h`n>8CsoRn3qX3;WM>&hF@Fd0@+(8x`AQ&@6Ecq zs~=Y8T)_kx3i131#@J0a_8o8Kyg_b935H8Y1h zghKa;D^BXJcIiM!rveQ{ei>aqeM{Uc`;ij*)6*48Q&IPNYG^|`XM zM5GCnbv@I6S@*7ppShJ-k5h@SIVVZtVelG*&50eh{ zpb1zlt!w?S(CU*lrQR7ni3*Jak_(#9P)n`OC8;Oi+4Wj{LMAHdYQdW(7k>7yZOP_i zub=h(=I0y^cSk@&PsV9ZRr!_KT5-Y*4}MfP5|23jq>ecVlq04349}^xp8?9+eh*uX zx#`(p4i<^ZqI}J0jN2l!YIg?s*%}*!TdL_te;U$M1G(PvP(&bgS64q$ez}*f6JBcF zzv}!1xLO;bEWXqoIkVZ|J)Qqy&<2Hyg(iy9GZ~s4l`h|CO7~M{vma}bYJ|+|;ZQ%U#1L22*L|+K2wARy6A@XURW0<=O*MdLzf<&_eb$@heGd?49@Mq9t$eUyn*s~6ri7+riw59h;O8%=sI{Pu#`&aXw98OMIg}{5^)pDIU;tmjhT_ew;U8#F<*94X0$jxyD@{S#7UDcs>LktU72_*CjTS)8DQzv z%eU1t8_dLUx1_kmUuoX2upXXIz`Er-+SjFB4m;(;38A5hc7^QA2Tl2QIaaKym-EM; zmDqJWOe?G9bA2GNWe2Dy$^X-Eol~q)IJ1c|d7w`#s}OV>_OMfe$SRP+649v*Oh=Q+ zccU5`5(OFG6w(v 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 zcmXw9c|4Tg_rJ4XFtRh&vXo?-v1M08hzi-mFeF<<%5JnsOd(6i5{2wTwyYzJLPOd2 z>7yisvX!mh>^%g4D|z zl|AH(l4Jk&D^&r&JKkR62jI+;y%7ZvEMG8maA!Au>w=+1)aC(e(xh|4zju-Z(bloZ z{nbi1g%5@IkuBgM@DM)BxlE>{rbM>kKd=8wO^D@8+G^`em%L;66gOh8CrAlE;mcmJ z*%K^CztT!^^86JFEt2b6vt6;alhm5p5sX+mtnV(Mid#Idm!_ucp{@?NCJB+!rhq3AraHEWMeVqFJJz>uBA;(o*GxLpJqk)qmfu09=%xH zD>qkE{EB3}k%PfzL(krRP|>2vUpd;-htB^ZwU0|=D?}<#Ip#WgRb8{WaU&YPF=rsz zHU%lJXdIQpwP_*yTh!Ke;wInA`ctpnR(2bEf36(x+H{Z2|5~-V;k-A0&p=W7J{3bS zl@O=K29DT!mlN6qi8w<{XEZa8n&OHQi}7PE{AztG{E`EwclmUfRA^H{!W^6_bdCto&^eH5FUo$`^f?xz4aB>ddIIC9tN`REvsIm z-C~`0yqhN6s{(T*885~Fn$hznvCh%mThjdY?>q{8C2{_W$hE%_iA^IjTe{5C1zi}w zBH(v$%gB|!fT9nEp1$K3@3@pzq!})h=cyM?uruXCJTwHkmm55DB8zH@8hLh?`^a@{k=UHkUbjnwkBf2Y(1PXcgLpV+xTNPVH7MJ4|y&Vlat_5Ju)q&Rkl7sPTXxy zDLTLF`pS{R(wJ$P8y0RPcE8XwN9XQ`Y3?xXN992?VHabgTf~U$h1#51k@IyIj;^u> zoMw_PC@j`rcT2O|^&|~4;dP+}pz?}KcYf&Ct<6-Ae6T?0Mb;^Z8^%>1i;31gM%=D& zoK+4YP;K70cJk-^wn^X3`qY@QQA6aX3{ok#`Gs=j)%x&S|5svoaL! zKFbQ6;lJVudk#rBNwpo_+;Ga@F2k=)`7#w`csT{o!Y)-QlU3ot&K4#?j%MYdc3EuMV9or#8&tJMmy@pX?<{rh`Q0BF3R1ewRuF6J{m2C`f?V6msR;cs z*V&x2l@v&Ul@Y7?9;ChZ-E_k>xA1g9eEtgokw09p<}!?)mDRoOmaI~jef<<6jMTNyo{H?;<|CydR5)+?K z_hAU;glQ#U2+-^@i21)?!cP>w{w$Afjs*vNm&Dz?UC(FsXHzVZYc{T=Agit6HwNE?KWxOzK?`>m+?H@QbJ>~OesE7m0x-k#t9EfuL@1{N{OSOvqeWPe) z?0pb9xez0ye>|z>k!o|btS1tPa#H*-&kfEz<6OI5cIi=-9Yq5E$4cye==BmMsn#>I zwuBsvbkEb9%0w}uv$C~c*CPe2%W`u}k1U0Qa_RA~!=t3%0WM!yO`nfxm$jk)-4~D;OSj>V~J_xdk)wXXJ%o=rhGI$=8-C@qsZ-oi# zSz6howvIHiB2GjP+j%QIix<3^Y})AbZ!_T`G4Q>zC!|k_NyI1h?kqOEjCjD?t2@3% zR#qx(wfSm!!ia{FihY4(19PkgV(hA&(~i4ckfY6TQ1U23R;m&l$OVrmo#?IpYJCz~Ha>&^lM@xMdoD;Z<1Ee_3r;BZs=!YNWonDYDQH?t92={a)`_ z(8ND1t9_PxbW-l|6Wm7WY2v*eaiXQkznzb*t`}HxqD9`+?LVto=TxVzCGfdqz56@o zvBs$EJ>B2W&d^Vx)fRke)RyjA+B9LDTev7n`T3$Waz~NCrIuJNYn5kcl4NeD)~Lkf zPpZ;g=uyA)M!{cJTrsho)bp-ATa`#44O_7{2GBMg(2$wTc}mLszx{Kv#i5|ZA@QsY)iwH?V!vPef>}d8I znETfs^A|M8^W(g+onQRnT~*d8qMu5C%Z7WOq`%?}zZ>?+iWV$y`zh zDD07MTCl0yx-TtK5z*XBxlHv+8g-%C6bBjy<=&f$=IyLqc+u(f_P&x%u`DrS;M6@Z7LGkWA%daz%%d=k)4N=`mfn^cV%{&isl6eWQmqJTN5zYUN!LC~EM9$u( zDGjO;%WB@78qQ{eaMl%q-CCl+rj&zF+1Va9>_C8ON_(ECEfb;Wi6BKx*(=JpGoTlq z_@=#yb*a&M*Nt*fSdplYva-^S?22o-ny$=DNpbfqSa&Nei94G*mN&=XZ&JKcSZQ2H zpW-Lts2EP^o@&jf-B?Lh&0Az3Dy(YfBtIg${0~L^X6vn>tDE5LsbD64uS({8PkD-k zm6b-sg=shnjM!T}%`nYZ$20}J0u@X4Y*Q!W^i=-A&h)BRJ~S835azT3ALqmte+AkO2eFqTmH?;2=m=D}S(&a`b z@)uG_uXr+e9{c{(_^>mW)3Qyo+(OfRX<@1DV#vZPuKK%dbdFmcU>gnp6SMu`)ccZ> zxVy4G?nRJmPkTx?!`D=TFg~Q>3|jQ#Wo5{V-bHIAA}cIlmNTh3%vX@XZmdTX(NH17 zQVC>VIVbWG@ES}b^-Wh;#a?F!(bVV7;OXX3CdUWW$*mC#{{I$iIu(DhP?qCdA-;$0 zwu7zkh6-<$z_|di2$<}D0dKL{2%!mca2nbcfA_F*LsAXS#aSF>926i8FCT}3tfj)a zNMj4&ooFFJj$C&b2`XXC^rc!Q?u#UBAcDdYr+0_3w40Nh=!o=hTQyQygi-W%2#gkS zw~o6oOhQ3rzA5r)lNE19Qy&&Ewc)ZR-=wi^iG(1uOIFzi7PB$fA(59tuZ=eArs!K2h8%;Vq!fjK4q& z+2nCmW=eZw2dOdLcGIPl9|7bxkC{lTnrUx+q@1o}rej{(A6P&_*w5WMZsIt4h2X+0 z0KtUvTK>~wuKkrYRK=+ttTH{5X3%Ifveqrabv`7QId!-Pmq=O zq{xn-@U?%Jv+mH!*ZLWVc4|eHae2p?XC1VN`qxDj92e^A5tM^X1y59-!0t6VZ#29FR`&+#un_S>5jdO{)0m$o3U?gz>$)ZCiYLUFBuSO1w+c&a)MSL*+F-{ zsO!{~SU*_|#cVLd+rG3E1L(F{>m#?ljO>tegbNI7l;6)LWI%V(#k(23(FtfU61AqAp5Tp{PyQo=`CoMQtG91z-Er0P)U8VannRLs}c4dA%0S z{4o&Rk@W*ZASHcMAYR?5koK`f&IJWBlD%RAX>axz{9Q-vRp<#h_~sjR#O3XS5(F1e z%RMm0ej5n>nro{{iHX`R7;x>yJQm-QHTDAw9cn$Jn`5_mYEHTBf< zTv_0ay-65dD=Sc(z25>Sme?MABL`HJ($X%NllK@BIX4GNWmcG}fl5e!l$m0bp9=0> zOB<;EFYDPuUy7SW{!!-sJ`PH)#=biwUw847jZzAtKU|0+c0Wbq=!We9{C#5RMi=Ia z$B7pZ)oUYj+vyy&RRGXT&h2a?{We|wQO*qHOl@iL8Kp3;GlfG4tYJ(5zQ+buX7bZO zw$~Yqb8*H5(1UWB{7s}|Xg?G>P6M}#mkJ?^5Qe_Pgb5_}Z#J&?zX$=v?^%}nMpFs* zak)(7*3jq`39z7zE`C5wXNM5*s^Xwv7X?gLT`+nb^2&@67|NoYd-=;f7Jx3W4IY08 zE34Q&d9n>obY4t)y=WCZA_Z-kmpgLhzS)4a-rEZ$?TiWH;B4Iw-u#e%!tXz|c44l?!Yaf)$9n zKid{1M8S&!(S)gw;9Cp0_D=vf4f&z2@e3x*u;I6IVRWlyJ;FO7Sl@vXXnx>$-85K& zI6VI-Fb9&9PLWqW(aZ@UxaA-gdJi_#J~e!gM?FjhN_szjZOzVfYk)Jkryyrnvpyav zg_YHrozrPE!hqjm;)~HAT~08&o#*1jOi!^`o^dl~-~-JDaQ?It z^)H&fbDO?E5*3P>JmY-Me)m?k%s9gg-dJ{1l1iDCR5Obg-Smu{LHfPWtU@EXTZJd! z=`tu7KC)3LU8C&kW2zS&U;y7S^n)1OwQ5vppzK*@WzhFdT}qtYnT}So?113oVWI{h zIDNQ7?McdY!=6F^>Jk>&BSaY9a51l>P*jY&<3mZwe{T^OaP3v2hQu_*PS6g<6q*R| z(u>xN8cDyE6U+8Fg{MPdfMRp*+~c{iwJy9&2W;xE_77I@W=Y4sxT9wP4@xzO%y^KV z5n5yMFf}h4SSyh+FgA!`*oBiaE`X-f_>|2k0IRuqy?{eQ{~l~gJr2)+hP0hy7{5zP zJYr)N?cQYpX9E|4W$&vl>wPccr%c}gA{0R8SUKL8MH@AOv@6ro#{XcXnJEy182BJB z$6w}EeWX~{w*cXTo^l33(F=VrP)1z;+;3wIjbz*;LbILrLcDt3Y8uT*?0WrZpiq|? zQ>6M9rndv9{ORzYK{eYkV5b>wmEkHnoqXY8wIaNyq!{ws4QH3a-0ml+rc~ zcz>jvGQPt~e-8yx*`kf(2mn0kp0P*qK85;6qD*!%3zzFb&Hdvnk7AzzBXHEAQ zvAX_XEVeZv`r?2V$@lE~920SP?fpki56U6G4jzp0Pb4a~vtWQKHB;&m5(|J3o{JAG zv$B6Rh2jU2R}bcqaB%xBQ@{yZ7y^)C@I&T2znqx*7a^ws#d_>64GB2`sABmj;Ro>l zP4H=7A#JCH2Ag1wV$pv5+(i*9%B6a-Q2?M--mke-)55tTo3& z)ewb8Sb=rX(Ymq+)wo9ZMO?*!D>$Y*4eHW-xiz zE_WT+=CnSFmRscfdD12HM3zrOTUG05z4+IhJkw6Gl#L3w(Mor@b2@R<#N zI}e68z}@ejW!ddl|H0_L{f5730FY&axMf-CS+Bu0J|<9hK6}GFIyIHX?y$d1kc3je z*+tw125ak~BDup@$(cEJ-I^khVGmPcV+CnlW@v%uS_sug23Q~~S#XZqpSCk8vaE-o z2#!V3vJOHtzl%ma=PHLrqM#^4YMkEr?kjf~EcX`Ay6&oQ%`yOgTem}w`Adad1WAbHuX#(W7hNN-1^#V4cV1*aN;;a5F28*|E~ZV zZ}rds!hB!o@qrY)y9btNC2|<`N^^pY=)Lz3^dJk{t_m{3)im!xnp1>6e1Sjncir@;?GIp3}U*HqSFHTpRdbT>Ro$J_pnS z`!5}9UR}|9gJL9Z8yKE>x%5}_%&J}>WWv>#Fy@|hu+sl(+36Jn2o^8cXn+^OnCH7d z-4hF0@Fy$OC(a$I?1aJ(YL>nbfcDjV(dQ*~j3J@YL{CFF(Qklwzx5fk?@AP;=pBA{ z;?i5U)sVP)7DecbhxvpGoiOVV#{j2%(e!2Amz!Qd^ZU`L-f0S0i%YTiEJBG~ex8(S zgJ@`D{b-B@TF}4TKY@EQn67sasG8&VJxC$Yai#9jDH*5`vHwwTEBEHMA&5uqLmP5q z@8fgULb@rp2Qe>}e+ zXuLZUb?tz$h8GIVzY$@}LyxD)-;n^b`p!+!rZTgMi=@clYydZa$NL=6zN%p)K0N3`PdpD1T$LV0-~`*nk# zpu>*$ErvjXD(ZLqb=-i5iDX)|Vf>`59%Y<@B|`)VX@ z5yx=MA;tNN9CQe--Kye|Dd@Z+3&t(j&)jgP)cCPWHQNxRh4|XR?^j#7!*iUMTiNj6 zg&`{rI3HO2BY7&Nd=I&#%SD_jKJVQAoB!}EPW$Vm%O!w5CLyL=vTL-U^G<2K*&wdk z@`nusq(tt`0hyk=zh$&kE*-j5XD=f~TwfiyiQiAO*xQZxESE`nF|KMnv~Ps|1`?Sa z_pT~LR6P_AZm-Wfz~CQ9vaKb zuzm1fU~k>yFz496VRud>FE-k5nsc?Bi+k^jv;`@E2O@j3Gjiet(D1l{5 z6JuW8(uYE86G~OUBaXj~VFxL-#IpYIJ|~2zP({p}>9I)0icXS&Vcz#0=FK zb47cE$X>=tnHm7}fzZ(-0juz9pSOJ<3X>Ht@yYWPWITr$E9bcDrG2XR z={JS>YA=!KAlk%56>BTpGX&*g@q~2=n7I9ag=^kP3 zO@y~zm6wgeHTlN8Mb|KlstA1os?RJe!UCq$V;}gantj~;@7Ghtj&Kv8InZ`1n(8d; z)_b$iOG3j?dqo(G@aT0Eu-bl1gP^k68klS2SjEYP7s46V{_TV4L)2BdRo2~L2lA&P z1wcNsxyC7RwUKDxD@+l$xD0W6?^g*iJZ`fc+iH?Q2z`PUWowQ)JM0v)Dq<;BMgEBx z!&veZl*Cq4*vQKu$#+vM%2ji_s{fLBm&J*XanE|c{i^>Uc?}wN*3l+`|48P{9Ln~+ z6TCq;Q{p}cVQg^|yg!RFLuG$QE9F*Ywjjx#GWo-#1@A*kJ$RL}XyUB4kacu767QtR zf**|BQjW^iPW~kE35J&+UD~|4ZbuTeWS8}FhG~tZUa@k+5;KN_3}!j&YAc~*ncDX& zHWnz-Yb3UaBeU>_meqphyPezolxweL9?|e1@DZ-0!r5CS;=JvwC}OOp-cLuAaa(W` z&=JzdRjv_VI{)voP8a>exZjwGc*iPrW#`)hzr(mE5F%s+w$SJ?KZJqKh;nVF{N=Rc z=iA15A|Zp!MdTe!U(Y=zCv)uvUg`-ag_@p!((3IwRG#M|bY(_ttQc}XPYXHxc!c}#Kx zCg9%A`ib)4kB=!aPk1Qe`7G5WoK=DD7x)=cqs1$^X4iSftvMr@4~a9Ww?BbKUrMD) zf;%`WRAESP=9GZ$BnTn#7Nn z1F`jd@VCX@*{wBuuK$i4rT7N=E8If}pxyFyS4=uT|Bm0+3xQ8%o=iJ9e+dB3Xy zdWv!r`ttGp3ll|*T5e;-V&nR1EyiVv_sO>_FrP>!t1YGUbv#{}@pg`}v+%$;e5~f) zDFyz)pUA=!tZEMz{8mHm@Jo~+Eli41h`I&;|Pp{B5)b7V<3K0ncS<0SW)mvS8*?TQvrSU6U zOo1KXY*W4~zlcIyLH{#^Icxl2MUD@`;;A?2!JYQ6BoHzjju0Pb~p5lkZO94gI{@C&tNYk4f-`mea=5 zM}0c43|omPz{admA2)X@9nOr;toDkWS4$wl$M)RGmd=$WaW_*b7l(p|6_Rh!l3LX= zn&AyrY0nPKrvJZCM6b~G>VPtbfnD`F>CFcm>2W0?ei|CeKRfDJ56d!Wh4~m7x)<=2E~#v&wnNAN zn9Gip(m^_Z6u$}-J==ZWB$8S%k^I!y%FK?B98*|aIUMw2I`*1#mg5S{hdPmb!`;rb z44L*>bZ2YzeBLxFBk|usx5nxRV;HtD`R~?J7JdN5b|x#|j1dkI;Duxvu5k0vNtN0g)f@ zweZReT}H(~r>HvrR(wKj71tk7oCWA zI4DZ-xRD@{xwR*^%z1<#eQD4`nGKR!T8g!iGkT!kFaKxG4?Kbp}+}d3t-I|w-Ya$yu|etDqC|9d@`?< zwY|qWAZ*dF=7L8dk#gUgw{tL5t{g*!RBK8;ivCmkv5RT}uJQ+)hyDh<6l!_MmrH(v zd*W*nKbF6i)$v70OgU(plnRTBtxXg6IBZ^MHN7JvKgV=G|3;5gzGnG(R!w`3=OUEzhK1h2(1==*5i^fF97-7wK z>UmA4(|^^d5D2lpWX9nz0qB68l2U$obMt@G%=&Xo%hD26RF z!NMh_3wY*a*zLY&2WBn0i%~Ip>uw)*cfXjHxev20BcYa(O5xRvN#?4#!VOCk=`N^4 zr+G{D#bq!i0{=sC()g)FP3)BV9lBB{BqqE8Z!!Q!olK-5F|jxj+pmT|TG;Bjv4>je zqk*H4yYJBKZaf*d#={^=xZae>HgZef2aK~LmfRW&pYS0KinvPndIq-qwczHceOp3VxUC>+IwH| zC~m5J#}94^sBI}L_BiP~7r|6IhQl@XlFZoZO83@RdH*9w|0g;|t^`v0R_9op?GvF{ zUOgj-$1+^v7Ta6aEPhy4ZCGG$Q!$>zSw*w+mZl4i9E?*j`msMb-i-d6*?M!Ne3J5( zlY%Y$_%IC9_D1%`2RX&giSW_xpMs~MJ}X7(mfdKomT*fv3KAy@L;tL2ns4DYDPa2= zCYdX1Zn3l9$u+q?ReA7Y!typ>)0rD6yy@GAH{*VE&7|Xgp0bPN?>Ql`bc91)x&U*R zGq87JD-!SlapJGMS&9CwwpM##-Jt$C8oxoG-U_;onpd#VLs4IG=#hThQ>^g(@ak{c)CJFHzto@Q!Gc*~q>!4V zz_w{I>R1J7^5@jJL`Y!0pwipQTNxH`x;7Ha=TfdRq9c!3P3`zm7wFq6E$Z~Kyv@Y4 z>3Mi-+zsF$Myz@|JM~$nOEsY@ee2j?YhL$P<$o1_9<1vrd@$PWDXXQv3ahrS`hZy( zWFVh}V}!VqZtqTHehM6%y>cK^{`4WfHL7aAOIzh%q|4Vz@g=A0W)1K$9vBI6OaYd~ fo+<&|EYkn!eJOcxv5D{vK##G(#d8&U_`ClH%ZJUB literal 9490 zcmXw9c|6m9{QqooW{#5OmZZXBW$vrUeU!?vSf-jIXWz^{CPnc@rACCJ$PsIy&U@d_wc|!<5dXm3xSU}cWG58?ZR-ZM-Z#NGq+rer}GEx zn1ZR~cVQ&h;myr3A%I@@=d~OFB)@UpfUrY|qr%ZLQH$GIBF36qx|_S17|qE2ROAe8 zM{b9xeizvm=N28!i`x%PzxS6oN*JcO5qIZR{MAfC{u#Jere=bmKE%u4)$h88UND5F zKZBoTtrAB=Ouh=ascu@(49}49CI>t*U#MG~ZDH9W{ih)9E|I@{m;@rLs*UHj%0@!~ zOLIGlj5oFaxqAR}6ecW6w_%UkEIR#6KGxT{OdLgb{0d3P70=bafw4bmZ6Hn&gQg#^ z^kWhHM&m;cy+0&Sfm!ZYW~Qm;C~y2-J-u|dYoO})ugsPUN5Ey5jAda;koNkx*8X{# z-P5aw5=%_J#UXEOn+o@CdW6ufzn`f=-Yr`x3$*Vt+MN%zKcdx4!`dGljn4YH9%I`k z{jpIH%lwOF1`p5R=R#<=vN+1T2$af{xaBRKM}()W^U_oB_m+01$A5 z^gvL{GCutC^ptcGaP01`=xe`>l?TXmRX;S4h`MiXpkcZ&vy;yu4%!PgGPTxkT>2LW$CL~S;ASzNFg(fq3x?7 zNbB0>GzPlEq=WNVT+$I(2d`%V}Y=3eb&|Csm=jsGH|u5bSAFwlMl_k-}co{eD4L3 z(GeON=`h)S$CQt)FWmCKv{h5Ru?=$JPsrhrZyWuqC?q$hb*D1E(k+*a9DS3 z^yON(0%t^wqmtaKFo#si#1R^$e$sM6iz3Y^VMtw_Tk+NU4x1Tfl(&F13I?fr<;LV7 zI~NUU!Rrs)LscMk>G_2LC9Q($F}Z0WwZn^A%hFHq8cUZN_w%v2;STdjlNw{~?f&Pj zJTaP=o7IiPW;20o2UZmU>*f<<|Cd_A`W~3gDd=hCh~^(5dHz$|W3^0`t-jvxub7?y zMAft8uDiNw3aeNSbGt0w#i(p9=Q#t#h*ujV{I`SP75`Ll~;ZlOHc2O^%PR8pdkn7J(D$ak%lRb zJe^C|qy3-f-mFCpSpu}L79ZV*(;uyj@Y4>^)jnX>$PR>z-A6bNrsLH*Qj;HaEsTIw ziIl(lZFQT&;Z$CHUsSTMnGdq{uT*nKVUbm{V0>@y-}SF5g*@IC{OowF6kb}HU#n`2 z_}oqtN>_wzk$gP&YhON~iq)fczrzGt0;4m7g`JL(@`SfW4F2u~TVV9p724D5yzDq1 z!EW91!+dNv>`e7n>g@Qq$(TU(aJp{yvrmouY?sm`8P&_A&0()>luXAXes-~$XBS*z z;ki#1rY27Z3DE}yEKr+0*1;BIB7(kB^|PdViGl0J+2I`;XbIf1R}P(7=FGWb?QX$)Qd8*?WKY#7xL@dnm8qs z`b>}C>yiE$kECR9Gs)s~&XSJ7^*=#H6|Rjx%vjV;&Lb_4{+jEyv#u=v8_5EFdB|cB z>WN$NaxSdY{#2L)y;-Nidn36s93r6H_NObyh@s_o{+g%ILArMeKARK0vvq4(rsUhg zxJ2a{VZB4-@T2K{^3c%!St){(kdxHF7*MRS_0X*BiytdLV%0ryMb0Qs>xG!9dx}xX z=r>6jd?1sMlaq5g{VgjD5Uvc!XpqcyH|f@FEuHCY^c~KIg)W=-dbssJAIon#5o_*N z=57)!MbAsU+Xv znHSqAd)eV>=k>zaf!uoT1(c%L_lAO=FXxdIdj|*87Kh{=cJtTY$|Q5ORfDV|>XQbF z3SKF8pl+KBoYZNfN%bk-!C`Uas6>0*>~T_$>5HPr4HQoYvDF(%RV2FZZ+Vh=^^t#r zZvuL$JFoQeV&C?g0MSuVW*mLiygA}p zbe#o>G|b7{B*x#)&Z>gMpMK8RKRkdMlB?@gXe`r|rrTah$)CU21w;CvV>8WU+O7OF zNSXOMIni6Lf==;8#4(E5ysCcAxE8_rfh|uUtBTRy{jdR?nj%}!qtq4sudjCjUrFN% z^!@y15Voa84JFbgO6YSj}Lli!)r zniADm;AfcZ_r^RJAJWSj;>sihu|H284A_B*Kfz1LApCBlimA_l(wWe<01P=foX7u7 z(WT|&5wtCqCEc#U`YNQ%TF>HnXX8aj zNjjtPZC-pWRR8AtQtHqr47CME@5I)E2VN6YGO)9?RkwATsLmZ4UsRO)tjbIKjuyrgDA-KS zt&|{XS*A!>1mI#roDE4mT^mz3m};n;+!MLibAMZ1;}PHtO*Z6F(d+Q`A|y!g7vK%- zL8qm5B|qmP9}qMoj%MDUcuR(}m%pi)+d-`@Q??%{bEWq^_CEAF!2TfE3-M&m>K`=k ze7Q5A)$t`sz}f&#KP-8_rqoALlu`HTG-QY{yuMYwH1JcM0>zMtThH$s=J*Ud*EK_* zrB!KyAwxMjhVUivND*(Y)K~Wd0$hPCO$Lu%Ru?_|6_Xx>Weyeha6)LUkPi(vzwtQF+#CN zEBq7Q^ZVG#-FtJx?B(s53ApU#QXsjz+Cud`Pk(OiiGz(cKTf{vU#(BoBGq5yQh!j* zMfr26|6K)NmEwcrHaUsJQFA8`&;(fz64+IKVvYJYH-Kc1>rZI(opYvvgTeb~8QPLP z;{zZ;)zp+CB^lcL5#syzkT&1%Z*^W@gJx?wr_}%;rp; zTfQjVJYj=)I5pzNdmdVw#zP7AnnPm%^C=&2{2l$Sw_HolTTh>^UkxeR^(1Ln3xB>c zZP`!uhQ8lk|D4uv06OcSJH}ML^Pw3HiFDk7@*)JoJqt{3$MZwAZ|Z)b+5oWli)1_* zKkBwZ3@|!C%Q!|-qm_W+P}&Z??EUG7Q`vu;Iq|AJAC4WE`?AcBHHkZNzfO`q)!LP| zgaUXU;W6Q;K9JF4bqDE;fmFxn7=_#B^1@+CB$nm~@zoZ$kV+f%=LMmNJ0GUkfw|2F zzvz-3P+zgucKMgNZhv3eb8!5o!^SjkAllTvKjmXqwlMqW#MxhJXY|FV?@eSJhk*y? z{hO^t$p3;Cv%NLSgXW#uEu1(r(Na=JEr2~&{NI0t?-o?YTYrL*(ik(sJ zS~?NyVs5h3GY*j+CEoaZ2zO3`nSHuVmWW$mbtAZ9=9f!fTN99aR}QWp?n6>>lQ z-b8mS#CJ!6?SBi%yf@JV`>!GY{|)Kk9WW1A_asS~dAxKf|F*Ss2GC0SbBKMaH4b;G2j6MaP z2|19tgOw$FpPT>|k!v9#$f*0``KIt8F*-JACypCOGC#O@ngUED19n}bU${_oh1}aO zJB&r=K!pff4KjG?evB^?NyXAV^Y()T1POB0;iD-9Gnuz(K*~!F`=v$~%`J~$^?iby zuUcoANj)^?(>x5(mzUJXw$s%hzOV!I%f4nmy44`k0cVukjbQpplE!*Wm>lc6SYUQB z36gzK@TD?4wpNVJu+xxw2wZ){S;wo8J1lrZR4#0W?qJph79NMHKR&WX_va3r;j$uG ze)7C{g~yPR37Oq%nZgPUOB8A|3hiESrMp(ZXD>|dwJdkbm$}o=l=2P^35lElX!8je&w5JIGBrvX=fi z^}7rmm=_f+A_hpT-Tw^J&e(4cf!Um>k$OMVZY;<6t~4n}vjku^#|6Ei}=l4l$RoeB!#8lF?{cqK?%Y^>K+^` ziA{7o{9{>+4g@?-epl~3sqjGqa)byOu>#}<)WjC*gkgb5kC!R4j-qwo;A*QljMzfV zImp)dd|@)eOoEQNk(mlQ+b|1F>)OUIDcZGiUkCxfJSq5X0?^oyX?dO{1H{yBuxgn|ZE z!9n0CDU-QV&+hOzAAep|&)UI?^Dw&a9)Ft6j5#YMq*k#s8kBGzB8b4)%sKv)9s1xNeF zVyNMh6Qt-ZgGGS_N9<6HB1!n@KHQ~mz>;Mg1`>I=I{$@+F|6*XfrWW+F~2nc{#Bh+ z*nABoH~#0$(6tLcri9AD{;1u^z{Q*p`-5q2)6LM(R4-Tjt!-^(Byn1g{GO zzF()XS$7&-c^tuGXOar-q}uTIox&1-CxpKgYXnlgSepCT+;VZ;SV8t=Qpr3>6TKgI z>DRpbup2}GN8&%%T!@%#uBnDr!Qm+WYLV0L9U$f{x4z&=hfG82Cc^v$*C_t=VV~OQ zP;s_~>e(Nx4q#`!)eE$yPi{B_-CG_5`AX#KYY9O<~J$r~9u@2WMxSwvRs6K7in1GlH%?dv~{qW9#=U*?Vsq;Un3 z@eI8yJ?QyJSrRdUr)#oPN@#MG{~0g_BvE-|Pnv7ANQt?1TiyORFvv3d z&owK>69RyO@;#y4D*b$Rp4kgaf4YW{WZO^Ve3*ynr`1t9BSI(ORJWNHjf5 zcd2V=c_H#XxF;ge;N(+_fTn=pLT2eqyU}e{GvqlQgZaGqC_h4$WMEOXOPU6%x6w%yG6NSx+sdLeqOwZ6OYLge1=O;?7OlwU4QCiMeaeQcSD6_5Dm((wXIROPH z`4ezB-rScq>uo(F{b)Iv*1oE@%}O!0*1~I-cK%do@qpXN0k0_DpW-E=m-U3EKVQ%K z-1euXpnN_;0`7Ym%Os9QM?V9d=VsJISb$NrV*T;8Kca97zxOt7X1`i^1Yq7$u5>5n zWCBm=az8e-SNeEbwp~G+T4w%(j5SCf^0$0aoJMGqG-Ni82iN1O_eHOLSt0>p4PHL| zETL5|Y^!83+xWf98yR2FlaeyK?r9`X-}jdoog7hS<&u*AF|z!GslA!Yq8=3!3UIb9qbIe;ByzmcyXUUzNI ztlc0YRe(U>enI3B$w(^l_7pUrG7v}!Ij4n)jlm?z*1lX#sJ;>v zte6Gf({0bemb_V-y1{ux$^~|R{M&c~`Cb_A+{GZ$#%pV9 z@3*M)5w(v=61}a%KN*2s0U$cjNu6C@dRp$CQRSBwF3aZYCYpCfdT9Favqg-bA#0v@ zd5SXVnXlCSOzjWiKlCmIqE+^>A6CfZo9Du86sI*TweWNo5F#-TQ5=wf+o&3>v@|Gq zRFwik@&`@r6%!ki-*6r4Mcbm+eMk2%xnP0#cUVi1mfual)-3z%(XIpWKUZwcFGEws zb*+)xR2~&UTw)_#?^T2f*n0Am2Ndc=LXXl_c&U#BT))tJdNL>`rKh!9%^DK`#EX2T zDo4+?7W)Wd=;|5y4}>CB+qz5L5EauP56|t6gXF~S&&B#(C%363mArxCdRfi<82Wbi zS(9-wA4&{h=XP1+24qz)r)%n&6r)$NJwQsXG518#bjw9sR@G_oweRbZ$&zJ7MZKY) zBte|ZgwKJHAW6;0_m*@*K%Kt-)w#(+wo!|049oXr^0QIFjzjCNJ_$T#8n&01MxS|) zUwxp=SQ`Z{P2V8*>?zstMt!gvgsVaYJzDUKbZ z#APPu=0EIFGGrheuN6b2Kief#>4+znl#bUTzEY9aeqANKaWL(>fx4eEW8>kDyv;A+ zGYwN@H;ygUgC)d9Rq4F<&Y0{a;6|r)4G+-CZhZku5dGQTsf~fo-Wx*1jKOTohYsGr zwZZf!BUetuy03QUEbDjqt>Z;kMCh>$c(5~i))|}AvuxYNPa+JS2ZRZM|6F(tk0aj9 z(AAv|vv8|ab9t8o2sznhrZ!TH2t>m@pfjc6PICqb&^G_o?GH!InqgZi%a-wpD+v17 z*CP+Jk^S+EoL?2(TzvsKasv#_FEyoE3`l0MVGGu&xhWOw8N692-J@Ip&4j=F$HS_X-wkgtxAumiEBfyFyP4rn10t7bviZ>|O`cZ@~3sdvqqJvZCbyb;JJZ#A^EE^X{a z_7}eQ3v?mfN#)4b^GCX10sp+%x5kTL8#s$&t(Vv!+U-lo>i5@#6>h`n>8CsoRn3qX3;WM>&hF@Fd0@+(8x`AQ&@6Ecq zs~=Y8T)_kx3i131#@J0a_8o8Kyg_b935H8Y1h zghKa;D^BXJcIiM!rveQ{ei>aqeM{Uc`;ij*)6*48Q&IPNYG^|`XM zM5GCnbv@I6S@*7ppShJ-k5h@SIVVZtVelG*&50eh{ zpb1zlt!w?S(CU*lrQR7ni3*Jak_(#9P)n`OC8;Oi+4Wj{LMAHdYQdW(7k>7yZOP_i zub=h(=I0y^cSk@&PsV9ZRr!_KT5-Y*4}MfP5|23jq>ecVlq04349}^xp8?9+eh*uX zx#`(p4i<^ZqI}J0jN2l!YIg?s*%}*!TdL_te;U$M1G(PvP(&bgS64q$ez}*f6JBcF zzv}!1xLO;bEWXqoIkVZ|J)Qqy&<2Hyg(iy9GZ~s4l`h|CO7~M{vma}bYJ|+|;ZQ%U#1L22*L|+K2wARy6A@XURW0<=O*MdLzf<&_eb$@heGd?49@Mq9t$eUyn*s~6ri7+riw59h;O8%=sI{Pu#`&aXw98OMIg}{5^)pDIU;tmjhT_ew;U8#F<*94X0$jxyD@{S#7UDcs>LktU72_*CjTS)8DQzv z%eU1t8_dLUx1_kmUuoX2upXXIz`Er-+SjFB4m;(;38A5hc7^QA2Tl2QIaaKym-EM; zmDqJWOe?G9bA2GNWe2Dy$^X-Eol~q)IJ1c|d7w`#s}OV>_OMfe$SRP+649v*Oh=Q+ zccU5`5(OFG6w(v 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 z_r^y7tun~`E16sJ#KWV3t1Z8j9v{@#YmARv_|8%83nsd@uio4lLb5*qM2i{zjqYVh zmpNZEi(mYnWxU>+NR!E=cT?1O`lYpW_vOe!q_KO-k$zTH9#?z4+1e-KB^+J)&FSL8 zUV+H=QNLhTV&m>x8{Kf$7zVjAJa4xTk2i&jhk}7$8lN2-9s1RH0)4P(;ppG2!f(K6 z?I2kC8&CCfekJ~9cJ2E?kM~PlYzgNTiyTwKhue%AIZz5wU_0~hzm+yxuRF^l?(JQ} z^wbf(cClo2&6n1%yJ+@Cn}@!qJfwYUMCw{Eda_TVbG?djdp{=FI%V%j22bJyv- zBML%|XKTnQ5`c5R#j}#Cke=0Sv?0sUeEF+#5t&NDAGQ`1-8LCSRrZI?7>iO%p1+1j zoU`~5ZIS6LPpeEO(;W=u=v&tV1*%wWrLc?^-+y6Vp)T`OV||*{eaVu4vt`%!>|7MK zXumkb*{u>pt{S}3mh*IPR$SJ%+w=j}bgjLI zMTIjO3&{@wtf}(5K9mLsRAerdD zR-qeV$U8(^&6dhf+UI%^1@$05*IH{>3yB;^y=sqzjceGF6REt&7fR0BMxV>J?vbeB z3)K3dR3nPl&E)5v%Ta3!^s~Hav?b)8KJfplv`>q-yUL%<8|2Uk%HQ$a=1v%;aY2K4 zp!EbGlnXr`P+aB*1Lp``+9{I8FTH@qJs)x+Ukc&L+x5*5_W;~)h_)Yfo)xt31T8E| zMZUG&N!-^~nOP%M_mv6l{gK+tB)X+3Cyw)vpb4K6!1y@*5Kej?0 z(cF4&O|?rpUkF_+vlAnxkpeI{5>-{l5sAKa?CcH#9JRylil-yPnR;YUV1^JR z!d*ZI^e30hnil!{S}KnAbkBS)OTebZXHC6DJ>5C7!Jz7@tnRb(iL~+P2c+iYa2~<#&}3 zO?}R|(k(lwIlZzOCQ7?N@{OtJc8m-XVY%>r@nq_Drr9&Wg0BCf0jSls>$E)W^>}u^ zx&AEUwf(BK(+B>B&g-)IuG``1x`&9|Y{iL^HoJRneQCl z&7sGIh_qyvrBZy{5foQ0N*B@Daw=ZcB%r~~2FjLlhZ_deDr7&dEH6#^CC`eS|+7w;wYy8pr4ByME^;8lD z2_~Mxs^bgi0fCZ!&oxcfLpvO3G|DPV(#vjBXth1_GRv%`4;=ujz`)B z$E6OSjg8C*Ji4C_g`N>U0m~KIX*?5ZmxWiD?{=w8uD%wzOV~ytWm%b%b-6mrteoZ{ zTsv-SZDSq00Ckm4c$V@zvMxy3`ugYbv0R= zk#&gIYN9^__~9ap|H(HjX%*X0rwdHG-pER1I%kD_4s;~`b>+&t*_>OhbGV{+o*pP)*7vNB#*j=#)g<^{wAX)8`fkD) z55=>o|Iw{@yLBDokP4K9Vg|%!mQ_CU+s*iO0!bg4{A*92lPIM{m&#_>KpxlGYUK=$ zf!PY7`W`4PS-YlF;e;`1Ys&U#i2Tl%Y5fp>P69TIKEVS`0+;1V`SSy9- zW~_S-n0VXwk)EeDh2OzAiRnt45%_k4J|4xklEHpPIv&-F+O44kp?nAtWL5YXF?DCe zqGqOJ-=^QxAw-C?2B^p`-u#zT_vz!xx}@N>PSH*+L_FkMzk6Gm$79Ym3ztpWAtt`4 z8RI(8x-N0q>%nx-6^mv6WJ{9%+J_-Tb1gElK`MNd^XBjEw9T_5G*>*{0uZ@PxI3Mg z=XmHWY9@m(t-*B*K7EC1pT?bVCRsl-@CVw&Wo8XvDK8uyGNgZcFC zE7BP2ZhrY{PU11-_ls90rh|chlc>E|n1E>6gxhH9-PKV6W!s_NZ zKEvVXuhIliw9@=YCmH?%&y?%ArSn`P0wVmo=Xq?vP~OOZ`nH8!rHT#5aNELcEVZ8^ zWB+ToHeKs&*6Fl)Y1gg$&sQVlpYNc(e@jrGjK^%ejJ(d@chdPPl7j1O+*O2X#6PZ- zhu4{1Vq_ux)4yD0hK9k@9zPy2UG_WZO}F!+?&DV`_l)mRT<6dfIjCuHoWa)ZZ)d)n zS|S5)_e2cIlYVNrs*MVmLI$i&xD4R;tDA2Zfzif#I?Bf16dcqJ>@sicx)G-LhK$&u zDti#EOdO&mo1^*IN@{0atx`}w)tWfp>grc>CeuUL#!_GAjm&u7zwS7h$>UBn?=p-I{znMnjFgZa%Sc0<`Sz%ug9H@ znb%RRz}eP8@L3F-nZvTXPDD#%7>1-jf{SKpw|MzqeO zx*cVLOab?wUQ`@B$NgPak*s^pD++cj(GY6vwS@X-rK#MHukCmb<7Px?`ANbUU?(7e zBU&D-_40O%5X$4MM~32mFQeG$H{3GsVTt_XhM$eItVVVVOdmhyX6i z>daIAQCe}j;N2DKmH*#7HaKlEpO}ufZ!ON_LZU(WV^Jo_K=6>g^bw2UF(Edcn^9%IW?*N=gw+Zy83ey$m!fs7*@@lf%PZP~V)h9BVW>L$gy!oMW`*2=$pnqR^gFy{+Ud4+%r+_>6%ox~|7~zGD zBH8+}lx12Ovk8BuI7}0YzCglBs-o{N%&iI70r5Xs{Z9xD`l_x7n~L@D!|u{L2>-NX zo}efZaci{>U#`S1`iK75WME$;X4$MYNfPjy&@(QEJINKv8B_WluA?s`-yg z+SCVl5wAs2$^q!C)1)JI>fH8X52j+4kMly9GEyGp^_50;u}jfglQJRvvB??XDDr4c;&D33jxfOH@j8yYv(n(G7;p` z4^Rf0m&hR&Z8U z{)@APNPBgfeja$HI|$dAt4dzxC%LvNcR02IvY!JsF%{;dxOCKwg|B%~!#FWkL5Mh! zS+noOCOl91dBgoqn* zCeZ2_q56CD<}X` zffs=#2O|$=*)=yWUw1Ivg6u!6qLyBn->Gf);&!pYEA1xVpH2SKfIY8%hDpUJ>_Hy7 z#A=M>UcMP z*lLz?5g|K`fDvx<9BSIDQIrfOhu!mkB5=NtcAX6R1mm4uyW0MDq*7nR6R=GA#aq35 zeEstqp%su-ato`LB2u$xU?ir)Yp;8hcvVGc_Bzu3we3DGydsHDOrA2CWIZN=cL&me ze9}Mkd&cGLnd1=&hjm4ydnL_&>Su=#X20?i!cF1`Dm{~JB-vq@Abgh%uE75wYPh%a z2_{%}vsvXMjMJ}>f#7lr<2Lyv{3sL;t~mMZo2g~VveKTche*^e;w@>+%OG-#<35N} z&<2R@jAs9lG35Lq6o=R5QY%f=OiO-tYp>q=Ug%oh_>L+6X8G+hHWa1wSWcPKBINWy zMDBG5%4_(shfL~$Jk|aeY=b*=>T1bjw$DOeG=o|#Sp6tNDBDw={-Y-{YigUx>^)+G zN8Mk^%!^)c*Od$jCL`w7Lp+|DXrrg@%+I7vE}oiSAmZ5PUMK1f3%p3HXz9RV&;e$6 zTrd@@L>w;dK6yjLe@`jb{5|sVw*AZ=Lfl@Zt17t9b-tvCu+*2E^aKs^H!Rxx&nFoJ z1a~GQCIqAssCo+o*DaRD-dlTx@=o8V0so{pu+60LX1eA4(*lj3;tk;C6s(~cd+T|% zUn>7hdx}miHq0l6wG%J12zr(76Uhz&4cfmHd`@R|cMo>u>T4-502-pYE^l(f{T|Mt zgEDc^Cj^%#w9Dc7L7Q=*GS(+0dhd?_gGi!B9~nR<;^7|tOOFmUObm9S&1Tlt=_)1A z()8;IgWBHBId)rqGS&Ved^pZj=BEzTzWL;0e)6Ts1Ww}T-N9pI&@w{`fA^-qo2w_a z+Jt(y-zOosTI=ha#Q~eC!USNS;dQ@LcMfIsk=MZ74a=;mIY(KW)RSqfQ9& z8wYt9-JdTPx|XJQKSuy)d!v^1W`8T7zTI18GMvD=9KTOKWBL$s9`VWxBAVu&H!1sT znX7>c^%i4QiU@_i`0*yr+(a)k;!z&JVf4ETGA!nb1r-t5N(mDG1UZPmYAJJUYqZ64 zrh>HV5K-o121gF^R$+$$6xge6-dWtSIH8uCbDwGxhxZxeZG5K7fNzCIDfp}7@arwJx{_Y$}3UnA?=vK;%O8B{~p)^5; zU4W>4gc-N5g!Dca+dlBh=J*%_(C}L7x?a3b{>8Z)WU_*F;e+D~WK>Dt)0u?-d_V*2 z`IM{ltbhCHN1rj=6C19LBW`+j9A;B z^$s#tD=mwX?!3tUgpdRd?<_J_+fYly)m-LR7#FKmN@{uw|NZCQqaiOYzwUHXrCM0{ zdQisc0{01W1rfFGU+}kr0cvr#@A1k-HMMadC!UapLA=t(jSIVGIFKb*KvjmB^m)JY z4Q;U;q}lbSn~C<8iBoX!dczY6Fu!%fQTdVi{I8$|rht7`Ddgcy>97;*qAsa}Jl5_i z(nOH{t!Dp^Hf0{a{XD^3LrF715&&MyS1@m-Zz_RULjzAV6EW#GO^^e!mI}axR4H6d>#O04WVuwjJR|`B zq92?qb(#Z#JEguI?`@^L$+QSBZAu;y*I+Ueuqm7oY990dD_`f*k%TZuxU z&w_MvhH0*;y@ffSC43b0=+XuQD87$wZ)6E4%be5--2VL|N`*p4)^1ZWDxAi*7{9>= ze2#c;QR%$tSlsAJD0n9auO;J`;n$h?-7oL1*gb-aWN@>l(RW7`X(3Fm!ZuWyUNY|r+|W)Rq{&}?D=x5_45Q4YcNW#N{*kER)EZ;!@|3lH*Td zA{7jg2!KAXSJKsAv09&8#%rS1+V{TojD`9kpzFF)xA@WFgJA`ByY#?Ld^2)Q;cY@2L(U zG+Ard!>?mR_TyoI+9qNYd8*rO0!du_sQj+71XGWuEBmV@fFQa|&hw>~eS?@R#2Xd%rPFAh7S@9mHgfQ{HVgN^NK ztJUl2UlT<9{7{!U*kR}1`(|cv{!>&>T@)qeYw*w$O$k6 zWh`w7FI9`}q-fyr6+xPfL$7M6#uF2v0RCu{tW}bB6?eVV`*ujPTRa; zgK$VzRMCYJ*$OiXE}Gs7NnX)YGd_?7FS@I%#t60@@Ho__6P_z;!H21yDFJZkyza`b zW?Q;oH0`U{dhlI`fBLOYKPA{sV;=zvq_JGFi^GiIMVO}L^1s|21?W_L6+s+?u><%m zICq9ER0y=VV4uiLq+&(583o8%gAOODfN{MxxSP-cv zgXAk{eS!Qv1WF8qWZ7UJc3RyjgqaS)oh`UW;_ob=fC5VVon;R%{!3D>)w_h{I0%5& z!8?0L_!2i5Sk@^Xfvfw%;5o=AR|3ZExEOkm#mhgD*`NSJz3@^Ew&!I<&>zZ*)+5Ea zSWuC^=a{vF@Rtq9C2a4n_dD?30{B|b8S_855p5@-VAE(R^IPj)0gapErUU>W%~Tb$ zR|YOQaaDjSec za|3!x41^H?@f4fhOUz!Bdki)2P6^Qa#<~pWf3(uc_`id{xSMJ61sO32HWA?5AdbSl z0-UNx{tW45zyMl?vJqDg1kN08ga}1{B%m9wu9_y1#5OOq!h;MCfd9@$VH$@#BtH`? zv42JYa5u>8JrOu*c@SQMMxy57SUw2P)%GVa#Qt}%Ka{q42(!*uNhIt5Vm0z7`hOgO zqPj1Cf6tBpgVWCGQvLf}jIx~z%ek2GTIap`*Ijw49*(yFL(J}a+5xW1pTAlt5ihbGHc5me~{Jaa#Pkc4*odGP>2{zja?TBgm&eeSQSnL|6X6wy|VII9j< zJx@{f0EQ6poDMtA{fJ*`VL-$vUUeMjeU^}1QZZc3lbjypuB~uQ09{+SjNP!Sh9(JtBrwC;a62g3!Pi*Sa0tMVY$y%Dv)HMn5VzqFYCVjGvB^Qa zx8*Q^fmD~^E!V0v+xzn=WVnH%+g~mSc>{^0t%$)OL=ESXW)=zk54oZh_FD#wXE-as zfP}x|`m@_aI35tv$=g2uLrGi+ekp;Y+VJn_G&izYa|jVB+Xl*a#TD}Hk`#YbQIZ;tSs#;eh9f-L)%_pISt(cn0rLZiW5bvAmlROSgVAo zly@lroN4$-pnk{a=0cc`gbHGD=>ffDD_OEgDsMNRia?gNL%J2gvEo5kMO+M8iK+Qt zt3=CeGcXnylJ|@;X{vii{AY|X`}R_kBnc5gRQM)Pc||XD$Mg#Q*ka%}4?D0CV8h3K znL;Kmg$@}6rPC{7O(U*RrF&Cw$Y+X%38Zhhu`tvSl0R+v1%)LnyOLNUAc*SR!j$?@ zS5Yk9z&+zn@j;Q%gCkhf66zlz2W^DG0?fb;`HJXXaqauO>UzY(8Yx3E(3UD8%oE)_ zK}9M>#~usqtuW9IYR@sVTOovs6a9&8eLU>{_?tl0>cKZrgIqXM=cG}4>Na6?QLj7Aam7J z$Wfz5fc1Q=Qkyh7Fp8G{6zQaHo9l#ZZq>?N@*1rXECsy6gW_tZJMIZTS*FT>dj;FH z8$FU7qU%jmSBh*{>j9?d$BRMFh!s3(w3YLu?Q*KwbEq)5N<&%a9hbei6ceEeXa+56 z-jbBi**eQLCME#5d8^F~5}^Qj#Y{Pu21bJ?q)J3`74I8;?gi{CKz*>BV8&>*!-o+o zro`+ZKo;))NUsS{1s$I#S_QMNrYxunxanD`$bFy`^$5f=<@GkGvfYcsJCkZ=>`m8%a{N$GusJ=v7u`uu zIVg|=3a;x+DOx{>c+mQ6>x5Lygp{eAmk#Z(dX?QoBRmXhSr0!QX3E?LGzm1l<9A9b zyYXjbQaNvs;k|EH6s)%lH$`$#H{$9f@KzqB-);gpV{0a!?1*So?RTyYGmcuk`z$no zfAoI#EewjOSI(7x>;`+?0pv`(kLTUO<{2{`-CD0bBMiAvVK@fM8>%7~9T!9$h!y2r z--_v@qo44>m*QyI4l3iQ7HTu1Nlc>`C)KQH%E36 zSo1eCB70W~!+LsR(T0`#RL@h~3EenjU^@ws9t=7RhOO_J3D=i(zia77hP%h=MY}pO zX2U=rC-I0piY4-7W zv& zjB@+;#eZa9?F>M&-kFzUbiTcCQ_A8B(eB+&MP%9>CqowvW)&>_mlvoMI09pTif!6Nr zkPx(jw%#Val!gsH!n9A7IGEJ7vTs&W$eporM4|fyTT5S(9Ayw5-$3HF^3lm3BzzF( zh9~QA4jZ4GwgH7tg~bN--yygtdY$adADc#)lJ#gvMcK7xE?I;fcA;pDt1}7Ty2B0@ zd;`3%k^ZH}8)=$`?+|?~y>imf5K|z3#_8ps4qbe;lVT=*!kj`-9O4n{H|FKc17}MH zSpMwriA0M##+(zkaNNzw~Y$U47rM`dg4KM`)v^=hK`AkzZWYBOx^nJgq zY_#mo_}3;F3%DI09gS+aVGd}M6T-;`hFuxoL{s}g@b&f+9i&jcm?7CVUWOfiiCNE< zpo2HtkuTKNCOKn!V)l>(c|$W2A3BKUQ9bX2qETKY9I9(=cjxhap*=mV&$ctT>T9 zb9WL7vb^vKdOyz2zDwN!VavrreYAP<4>@~PU_ml<$nYmONY>M`RF zrjB<5whxB?9k@hZ@Q zx1`D>2W_lxl0S{-9XW0~j;Jv{iq^r_qA zA0|d=-MEx9^wo=wS1TmKstuT!_!0;wwfS0)ql#%Cq!?s2?{YrHbDDSKGsbre`uX%K z$#T1pDQPH5GZW^nypmOJfY}bqj}>-yyxjq48K-_uJMZ*XGxujG=JfNyC(?5~kF6$1 z2Hz0}=~cwRi@%4*VCsidgD_3}Mz5Eb5kqLB$iR*j&9^Bvm z7Q}ZLPvBwCe}&S~CVzU%68lyz_ClgpoC!X$b{bN^>*zkj_yQ9mgbO#;v_hdV_@(x4 zxS#9y>ktUDe?^JfJIZU%aXW@^7?jn;S|b)WvWI2hIf4YmCkSTiCQ_L5d!^u|=eyu> zQ)+ljaY0YGf60G4Yvl{TZ`wvsF~!XJi{c;rCX8?~M1ATboL?)vISgl$%fNM0^tSt{ z`Rz7>{!KRCayV1mHwco+LSLZxhlq7a@+*m12_bl@P)2nfb7(}os6MY}>T=|7yO-kr zB`clp%5U8zl`&Tgb&#=_3xV5>#$}yIo3&KNsA>ZaZ=Y8n=CS4YlGv8Z6kZh1NnTI_&+ z_nN5NZCdz}$aH5(ZI7~=pxXB!p{CU967Z~q43yfF3nokREL{w#Ls7*ckVU*6yO?0{ z37XEgLuz4+a#?b5N?^S0iHV8&rPVU$6GROctT+8L3;DjKU7~N>0L_U2j$G!X8>QZ9 zoBM3dI#up+=*p7%yoJ_R*=!UpBhGuJ!1G^b;X)RdOG}IJy60?9b;>(}w-CRuNwe3|j;2WT%VAQbqwC16vE{g zB^#u-KLA$g53U8jgCe$M(+lgM8j=7}Q6GRb9@I4kH{#sxN;KvcEk;@WP1z{T)}gKv z7LB5x6IXd?kZ;G~WEd2uX{XP*LgoX2VXhg+*uK2|EuH9yZUf8U zY!Chj!v!Y?HXCznA14}ks2k%vNHLw{syjr1J`(NZg0mkV$>O8CVQcGlA6e7rP!THld?HVQs`?0A#ktjMft zM~KO+7_rbYd4=Hm9zgciN+Omdkzhmi@bRF5f84CM6$oS*$oWK?Ptrnuf^-%Nu$Fpq z8xZFcw@fktNTLAp>m_OFLSw@u2{U}3{l;1=Ugy@JWm3_^k@NG6JAvqL;hO~KU$M4 zKJdN1KxrK7k6%h0gE+xgl#+F^P7e9rX>8DtNPu- ziJi+u*0QT!{!P&m8_^G7jV1XsPB=8;sGb;jjI=&0k6t_^hRXB@4Go7K%J=F}kn`sc zme8l0u?+$F%9=N9EZp$%_dw!{_(t=37Qd2RmXB4%M5tx^0uf97an~z0!+HAb`$)2g z5c2uM7Vl%6)WtYT*+O2!2rEG=cM=5#AdVc$chKbd=9L|y18)n?Z%HwD zmzVYqa_=q>=nLXXJ|ra9Zd>0KTY^6fGUrm5M_H}l*0iGMkdhn;!PY}9z@hM2G(3V+0P-JuJC{UM6BJrN2Aly96AyRMp-V7wzrqo4p@ zoZq%Ec~_ZgZ7{ujEkM2m`yCDh_6+>^!)`rN%_nyhj$xB7PYaV)@gcV5 zGDCn~@(2n(M$K*DVn1BSzK}t-H+=PjS#kVWm2|xpVINt}c`o??s`vrOmD4ZLI`&S# zEgi4gnCvrTix0pHREI^cKVtGlMD5ydPRiQjL##seU)RsOn~Tj-_N!OZNqv8k zS+!+>YHUcD?+LVzeNZdDb9YbQ2DSc0cc(_g6WRp}Jx z`IO$DJYMwWacbGy=E3MrmsFYSoK4J_v62+M6jc@Qp@l2LdOrexKWw4^K+esRh>eG3 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 030/161] 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 0daeb389ef14328ae1a6f67dc02d7de589c12004 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 26 Aug 2025 10:26:54 +0200 Subject: [PATCH 031/161] test: fix tests for qtheme v1 --- pyproject.toml | 8 ++++---- .../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, 8 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f0699355c..335641e2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,13 +15,13 @@ classifiers = [ dependencies = [ "bec_ipython_client~=3.70", # needed for jupyter console "bec_lib~=3.70", - "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 zcmXY21yoec+rPV{G)Olhh_DFK4I+(nOP5MXcP%PNBaL)RNW&t%3P>uQOLuqYcYXig zo;_#p&Yk={{me|5x~e=r4mAz{0QiauGMWGYg#!R&0UHXIG}b8B0D!orqRb0zuk_tn z?-V0F@AX61{W8O`>s;OXMnhZ9vLq`-eUm!xpOesKd|ICVcI2xi^ms)$L>h|c!TS9t zs}w!!OU$=aX7G;3uihYWf5Txkw1#ndJrVYbWp!f_qqAG(CZ${NkL4AO-PJf-bjHd` zu5ZQEj@0#7Hl>sID*g)Zd<bJ75EVx_-%oAOOG_Fq&H-X5@vtpP1!@EL6L`r>s&Y zIdt5&?SenARY$8;`og9VHp0@O@IrJQ{_LkrD){fh%ZnRJHd0IMz=szq#dx--WIRwR-;dJfj1h3ttxwR?MO~pVruYN3K+V+Ek`%lDG_}R3XufMFN&KD!hN(FRo zmjMqMN1(Zw~s}*iK9|FX=;ykh}B=)Nz+B;l(QrnE0;DV>2g(6n0?1H zGr=}nkrJ|?rAghQ&jh~`5@P;4H*U3&OCHapA=bNby?+aP%l~~#-2aT#F^ziyRxbfd zrb7JdvAZh0>n>ACQTLsjlLae!N!gOzQN4F{FUmxUHd9H|6G1SS3v^0Iu-QN$OQ=U> zq_l#G3az_f=936>(ZWqigyp1aKM4MFhpx)>o)muinqS0y9!D3PISsE(oAO*?7_3Lp=}Q6DcS3n!6v4} z?}`SuI8H-)&0ZGh(v2QJZ?Y4$;CU0NYM3`tbx`}9y6P9PE1$(DW?dl*0u{>J<*K33 zukmflItlJ7iOC3EN1KXv@YvYh(B<~{ij%0eVada-sj$04S%2*< z_F6=$x?S0vE{%N|ZrubXYMTSK#UAecPRL>M)-tyBz-`{k-V8P^Nl!y5c=aULlGyHf zpuJ&Pz-DzSrvty?w4G>LiGdjAO&AbjZzX~_AKhrxT3jE9{7jL`$2sP+9@kr^l4zI4 zE}F)kJfWkVJe0a&HM&C=LCcUSAt2^&?{#%?x#^0V@u{L>5K&$&SRJzct( zfEZcK%0uSZr;+svkpF69{JF_s*SX_U4&<9%{<&w;$jX{#}~R<6vO2v z8uhL^v(Y;EDmr)(K}RycwZ^=@+xbw=lO}mWLX|(ao@3~BDt#r0+A({ZGJj<|2U+5Z zS@N_{^ELtQyI$l4$L+(4tc-L$#|nJq()T;IUe}EUyY4wRvrO<`%y9pe85#TY*c|aL zIf9HdyFJ9{MshvaQ|Z*OT!Z?1BQ}R^hQNZrC8_(4hOmY^w3$hx)3fs5#@opVbs78w z4Cs#=e6`L@A(7^9zAIxvJq@ouV)CC>Ber(w+$X-j*RRVD@BhOf9~!^@?JSda)>FjK z$;{=Mu`n6aGl#O);!@{{M%Fjqg5us&Bp*VSBM?w+~$n0*y>4X67`SKuC4y zqB@)Ti;Prg&LYH_uT?*vpGTfYcfRp&D+%pG*?PG1&y9|0n{HOb31*8CAy4f{dKo>xr| zT-vy75nujouQ+2u(`;!uLYJ1BtgBUykpMt{tQa6#&R4qI9q}Z6ZDGE?-PwijO%0mt z9G2ZPluO*o%sfLo(;`y`(rKq=#gB{zyw09im9R~GFKKesI*!&kwP(c)r-B&$y6Vb} zp%*!?5&T?JZ$8X#y?r!oyq(nSu3{-%mv{87r|2^6A+s8-_`To0*I8~^67}sFzKW;4 zt&Qb9iFyL|!tgTo;nCNFk6eU1+f;q6-M(W-sfGf4vx5!!Av2t!%NomqUGnsjVmBZh zN%h6ylMU5(x5->h7P8{F9a0)haOE&-Q8jv+yP>|f^OXIF*!p6nHiwtXO@j5$D&B5r z-TAZ+eKuwA+BxVx&_)FI(WI5m1$I``ObFO$+d?{nfM@txA*UNTuJ?jlWVga85R{l3^G4hXSIl-6}hev48h_#pDe+*@qBOVGjHvnGUjIn3=knZw=WJx zKXRSlm-s%`DvJW4ttfADb?p-nfdBgA(cud6iO=oK$P?c2-`lB2(dEAhjfTdjv~?Cx zXpGnkOKyH%TMTDem#1M)g@W3cqn-}-KJ%*%q(2@twN`Dk(QX8uL@nCzF0U2Tm)Tql zo~?FptDB)aISY%ET>f(G9mY@mz0K0YRA2&0uagBvaa(vI?vlumj_4_^`aQ~QUJVdZg+yCe)(N7}ChzX8s<@D%G zGE4lnFOS(K@U-_VRdTZRruwWAI#2dx%WQs9a%bPanCq5`aNlVDA~u5~t#|-R9tHeu zDl!-M%D(^1g)h_3*ZB-&YYCq};T(FS6nCJzWwP-=vhw`Z^i*m%{HRF}(RzP`Ai^&Q zl4XP+cM~ra?=3otkHjN59JLbf7BhD1?ce1@JPwcF4egp2J4d#**6xZ<+n(OLsC1zk zQ!j3yxG#Zq<$L{;QngHlru0I)EvzD#Y*-4%5O23p9q8)rPaW*uh2g*e zNV;#5!~OQppf=xfuHE!wl?;4_77rFeSV9tLQ0B#4uX>jAirch_)$g1PuYI}f#ChotjtFGrG=O|a(a@YjQTufp~2LANa~2mA7?n)-@O zqhDbJNF!pT@WYy&`&++#$yPssx;!gO>xCFZ{d}tpRbLC6JAygDV`FE2Z=dF?K!GVv zQEyvlH|F3z#5^|{s%@jWS4^aChW(((DVn&ib%!)VZcVN!{& zrA28*Slh`3gW7-_)vR-^Cxt?w11;tkKUCy9`*b@!ti zraQu4r%64vl*iWRho*+qH+zgBoyBk6u5jDLGJTR>g8aAMr0Zxnwa}>o=?n%kGt7kv z#eBE2OsNveOT&L`_l^_3H_WM8!OgF!vD0$y` z7#lj{vcKjYmDOs_uYw70NC{1y_u5+FYLd_2j!F#TnxE;BGru5*q-QdB#F*|~r}_IQ z`Cfe&DGInM<8YiC+!>34uxuR)VznKnjc8gY6|yrly}4{_`ySQv?t%!CF4s}Hk8-v5 zJTG$d2N6h~pX!ujHZunmY1Xx@=hlb6qK(vh4K@xKU6$={vCXEw#%>Ns%=zE=$u!=f z($XZHZ>f9b;GNvC^)^ZWqbC)FiW3vhiktVx>vmY-6-3Bq_a1&1%4+GUp$0t^A5*Ws z*1OMmC`RacVkCU+niD#&Gd=HsO}M$Ks9)cpCXAX2hf15EIFUdr(xb!k{M>K96JpaP zJv{4&~U)yzC{g)d92hy z^vpvab?h6D`3TN;1o>6d9cJ8$&-*${HG;l$NyThyW?>?mV*ug82y?8Y|B2IniXHuT z)*}D+6D8XsY0ZrsNWI~4lkY`@cVl!kBGf`JA6WNIK>R=&*Ti_n$Qs@6=<$DQ4L~)L zMQK|?gI(WPU5&)hiStvdwCd~S+^&`xpQOEPIC2E;x4H-Q&eG$yxGU@{8txJ0(?kzV zrE;R=qkQSN1Bx9VU0SGX-J{BN98K#-LBx)_Vjw%%haV~a-pt+wY+jYoB>A2FR`s}o znmmWC--&n8OFj}TyUC;5ZF3!SxwFpgzjy}cL|snDKcV+_)o;AZad2pBbH}*<1#uLX z_B(IL+!y0Ywr;tOD^#2R5fa~}u6jd;ByMj!U!NUDU>R%ed80}UpXIdhs>1^A))w@# zM6C789WF&hw)UFpNvQbFjEP$?6v3v|IZOW@weVXCDHfzrHDxmWpZ~M|q3edZc zDVWUZ-kzc+T#2Z9Kbk_l#B2kO7TYx`F3zSG1o!moeH0TxR$+_)ePD^x4UJ;MaZmv{ zw)&#&I;&&)5hK^XS!8)_bXOgZ^iT*2==m=jZ&Py!6VkGoFppHJN&0ACZ8du+uA0s_ z1Spc-dudGfO2P+JIwa0U1bu&%-v;X;Cj4%r@e^qSF-cpVwqNz<$>;AX(w*iw-j1_y z@>_8$>3@Bq?kIG%c<-s8G@rq-eYX{Tn$M+9QsTB-v8UhWT2M+SulC>jqKR8I8aB>V zF{p%hz}7M$9aXB!Ek$!x&0@s<4o>A)2;0+;RlhrmS+xvGmDV4iS!VDo{}zeHQeuyG z(Y?^YAnE7EXTfUP47w#*yIZ5$zgwj?tL(2i)M%5Z-S~7d@2;E1E9}b8w#wlc90Py^ zkI-S&=V%2Q$^c*2k*t})o`0zRDPd@zoI%|{hEtj-D%qvw}A93-}B+sAt5c)5TesCfTCs@31gw+ri; zD_!33!{~se9Oh*W-VsMgxV$j>fz{~EO8e&m)%g9X<^*@s?B>pikAZf_!z&T9?IVFV zgNuqhl(H2oXhIf~Sw1JBqwHr%r%{1E?bO2*Y`)<{S&im@IJaryxmuqKoo;5)g;DAj z&SQ{%940Uu@z}e))Y$y=Gfjl%&Yt4hAJV2ycKEhQ#Bs-7q17up@Mtc}(-0BACQWhJxeh+C0haN2tJ+LUw@1>+cx~gElmTD&>uwR>il|f+D_ri z;O#1O*_*h_Z`u$nF#u=SOm&&(G}c2jU73sDtQ|>8fse)73_bl8Lr;)AJH^XpQ$UK;00quc-lxmRf9;<>0X@Tij1WGO$g#EG;H-0$ z$h$cCHo)U?@0uBDRNMnI6tQW*QimN{c?gZCqeIJT+#0wxc-E|sV$CS*YOF>Je2wn= zQRA@^E4;0#?d?ES>*IUlz(sl$i9t9UMEqn(#|F39Q0vWd4u84;S649quc97 zbQKcEi0IQl{9oq?0oVGRg0;4}h1dN)4rW_HZ`G`QB)>e7r9fV*Mco~9eC*Ntr{^4! zYrWhhf!+ZJ8pF97Z=0jUzyRxmhLJq_SpvV3A5bANhnvpK_#Qn0Fos#W;s=Z2)u&w( z6?3CYBC@+&nL<6zifs9haLnXGuQSD-izQxQsTY)*2E0u!*YL(*>~d4N92RXVIqBea zde{3SaNt4G2MumnQY2xE$Jyq?#TAaM26JmeW44iNh}rY$ZRsqZ?cNoRHesrOmAV>} zS;Ct_edxug!b(M!Go6OZapUhaL8nnhxL6~|u4zq1T`c~`Uo*9sV_)rKxfHQMCo9tj z?yE&@S=R!s;qWw`@-gtu&^t#% zIzBTsId9TyoR+yC>037udHDAvfboeK!9P=7Fk4>TuEMFqFoXD2Ljzq<_H;C?suHf^ z=pkML89ya5)gS=3Qh^@pp#JP7TsHB0za#?-`p;mX8t>aT9QTIpHo zSqDw*dz3<-B9PhOVA=OKF$_uy66!oX(g5+f{u9rd0dT*+?yL6sp*&m%b5w1%GPU?E zK(G1f;sn1m{zrH!j!;AJbkEAiPc=MT#NHcazT>;7EFM~1lzhYopbIrJ~IyzJdK zs`_42%nmY4_JK&U_U&J?zPO5;-PXCFs&|FwXt?Tw{|rUnH&vN9ABhTE?AbbZZ>D_+ zu(niQ)~7fA1V`&p;qllvq3=Wu+JitqiSI>2=Hb`me`@N7NRjtE!9(Ej3ze32)`{`R zb)Vl^Tl8l;A?hAD6ak=uO}a+@oS$xX!xhhhke2pMZ|}`C^Ii7Ni=iI!!z%W$tP7nW zH+g8cXb;26?&Fue_!$LvO3P)gV zO9mPigtTExU22;A<~A8Kgthp$Gyy3ayshQj&1HA&svxo6@paPbEuj8*Z?e|@c339n zDsdYUY^(>9-GG=(IaEpgfD)Z5T%I(sh7liv*){JcsF^5UQ9e29NM|XXx!fp_$9-C6!7RLxX z zG;;4_;k>-w1{mkxx8z$}x-K206m8xt1h<0NRCntES#&YtHzB1qTk;hKKgXE?78RY2 z&z|K9tw)17i>?pxK2#B1;w@Mzb1$;Q#)<6zf1Rokc%K zsHA5C{ox3^np`~Y(YrGftpina$7e(X{X0qDV?RlG49mMC5+s;7i?n$nQ0!=|;Y;<6 z9>PYE%vFIxVPbpl#V)g`?x1X34AX1up95|V6b9aPl%~B1 z>-7f$*Bj_jigeszDu0a zHlR0-5BpB%y!&vm_?083=Bg0<-u(XS3g)V#v2j~Y0qb%TB-_ftNcVbtEbK)kbI1L( znkB6N=t9CDj1*sgZXjD4iR>i7|(Ut9D4X&}`jU3yGhbl~>$v z{gn80jDr|L0!m$rTTfK@v^!oPB%#S&xwM{j_kjRxy$g#9-BQ)%Arj2c4l?Pjub-RF zrN&EA0paNO?4ZM4D)XApOLFJ}9vdB;ez%0cI)>YWw9=7Q4WcY9Wx;w?;H zM=$I)-VQQJ_Y1%fambT}IP4ZhcCCIF^$NZThO8~v-v24Fx z{@veKpmQZUF35Ekg8|ocKE7HjWcOdQzW;H!ZMk04%s>=D2%5P-pDF$u|NW#l#+Cpc zQr=C)d%r+AzT#%2?`_7beauf_t%c-)PlSZ50-1W4qvy>9*0F)CKtlR_tD6C+(q;TD zowlU*Q$Q84R6qF-)1Z}PPkZ|k{LQa(c))6jK>mDyqSg`F<{`EMIoUtO=Pw>s;Z>5` zyzi3g2pkmOJISV{*4`n`-7a>7RD{0_qKwsP1H>Ull#g8>ZzbLohU2G z^0pCOik+B>`4mY`j|6jcJ1Tm|QKEu1SGdTDy_ub9w4;qh^D}0q_+YF9a3jk#K5SOL zjQ-TYa2;G_9xC#{P+7sY2G$dfAc>7_4blvdyV_gOO0uMp5V7So3@r;U>g5f@v8)j)2J?!4XV>U5A}R<5+q@h z9nO2yV39^320&p`;(}r+H}7e$&x za}R)kv%jb1UCVoU=-ftY6v%e1DsVT6m&;wHA;(p*-0=a>`xD=eCZ14Ua!~TQSZ9dY z<9;r9me3Y^;e2B=f|a!UkPaecP=vo}m^|^fxt6YKeZwi~yxkG(%Xj8@plSU14F){& z{pjC-n#V;f^feU(uLxARaieh8h>>cv zR7!iR)n$2z)XTR(_jeJ}$;L;|ad)u~JBD&#`=2ZNnc<#IPt@k-Iu9s-H&uPNMKgb3 ze9uztfb%HXdCK%u?_#?x_fbhSqT_27wdmY)&WcmK$u` zev=b#q#%tIUfr0}vh3liulU|K@CT6cp04UWo%B1?78(&$`7&Z2Mg| zy#a84FKIL5Aj|;jWhLTaHCPAP+#(QBha#Je#}-BCFaG>2jvZ%gArFAH3QavOyRuC0P3)=|gLi>xEGp+cxJY{`C>&#Y&*T@X>%CcEA2|}ioGHnh z>9?9XhJV>Y2e6qAGoLi@E`!l?*+Gy!#Lv%*hgQ*E%C>k&kF&Vv=*4tnn6phUwq`cz#^XTI4RE z^VWwKFncyGpreQNkZ=x6Obl9$wqFN*=D|Gp88Fe?QE08h&Vd9fg-RX?)cwHZ)vUw6 zVYURP)CJ`RR_2lf^Z*7cs>uqle(7Ff7-ty_K$9X+IOVIT#M{x^MDyycj3!VUXdcyG zzLnxeQLYJaM ziT%5+BAi)j$eC(>msz;Z-LoYy{tiYY0`|A`8~kc{@DL|qUK2pC!osP)kL zyejDeprVbSE#+T9nW8nE*qO%hv#^@>;Ax>)RU}xLb9!vcDuDr>U2ss~+5s8kvJ@X9 zwe+FdMCj&86c6?pg)r#*8zp*WDC>hAH0#%N7-Nge&?U9yV{)?$0_xbtDx}iaua$tT zZ|Kwj&I@+uMEW*1Ul-s!QYPNig9HhDVHn0S5050~0 zzVdciV=LRglMKnMRkcF0(TGzfLdtf*34wEgdou1!4r|sOZgSr&LGKHvp9~2ewmIrU zQ2#Xp8{3xQv$pNv^5X6??*a6l{+G68)>t6rHSD59;a%8f49*x%ZG2FVKz=k}Mb|Kc z6bXikGMCO+2PwYj{m;RfsSVp~55J^b{X>U*Sf1rC$_xVfWb?Tww<>TQ3ie^#N4R@x z55)w>a&!`ZV}N#+CD z{8ul~r0x{+=r%PXd6GzxdlkmR$vl9RgaSmYm$cT=D*eOzA}azc5o@6ULtF+-HTY^kZkpQ z`S%^5%u_r=cj|ZH6yE5ULX*ltpOJ6-{c0TUzxXR=wsL2sv^)*~C%u`<5rmKWd!m{U z5BBT7h{FxiO*)poVHk~SmHif5XyZ-<6X<3zc8`Ip$RS&up2KHRf8D?t{~M=#ksEpz ze8Ox7r37$TH!?p&8$^O5SUiE*;0sBCnkGoW4#{eVymMlL=S9LHNao?+@syAq!cBRk zrYrz32Ie>F#Xo%)_+v7(J*gzSy$L)t@wzgh6AEre{TGa~t{ zE|t+M%^yd@3DBXSRwR{!k=K3iApX=?{ty}9WRCM%!-xeSh#CaI1%Wt)+r9+=u`f^I zmv1{ z@J9j*&*>ZP63lT@)zARaNNtQi*pm!AuU;IFV8NxG(IGqXVZ=^He|fu{Q%E52UzQIm z10Q2>&o=o&YZ{K7e6msC%RweEC)qoZ590MGYu+ro)Prc<@_LI(?i0 zsYL}2e{HkN@a@l>w>u7LpaK<(qp$f_a^es{43a}tiTRxzQXTbO&}M}v0&8~R$7zv`X0U24+Ju=6Ai_?}7J>}k*$&EOre&2CW_qg);etoB%wy3f5;l)q7K;W{qd2;+oZq zWTXUKUihRd5gJ~||7K_&O$xRKRqlgL=aS-n$!qN-;LQ_owe;VJ9z9zol&GV_Qptgs z#oD8iUORRF!CIu5YV|ILp`l5sDiUf2(;+32IN=4oUf`O|V)L<{(tNpr83KJ>k2F4G z01?dM3OekSez0xQ+LCHKJfPtbC%|!NL31$%8}l6IsZ`N=2k5hH5|>n?P0qi$Jp2Yj z;{|mSMw};51ME1~M8RjbAA;*O`?*5Qjf=b!PzE@{A0=8C6n-7JhQN;=i;L;#SVAV( z-#scpyg2gU(ZuIn0Qu90tc5)wwreszW<2+iKN>JA3o+tMT?D`x1TPCk zgYndt%|8c;4VX?_Fg}i-05=B;krF8P`uJVvFBl)tPjNiZZAA}(siY}bV*r5SmKD?S zK+5wNK$kT5RsrDlEN+TP3H%*->5i5x;=&C|nSlbKB7vDy^(AJd-H{CYGXUr}yQ7-= z!v+7x!@?H-o_!@gfg+NEyOtF@3x%fqdk%I$^-D50d1F=PvJH22^F__f5&{Wm#pbPr z7%71vR!mxtQm?(tBEY&%H7l2IuNOWC4n*rj6B4(;`a*Dw|h1@!!3x>mJUq&dt z%vw-9fkKKo3qbn7B^8echL?%Izy#p@DwdKYKt4fWaiE-sFxvnw`eY0`az9@_>+b=;|p#;GM3)QNB!FkPED`Mzsj3^ zS>R*}X#u}q)v&IrhZS_*5v*Ty|FSCPAIHAyg&?1-EWOP*=}cdF2A{qDyHApYLFfo4uy%{sUnt7L)YwXGHKEvk{eLPg?Xl#}U2jw&g$~%ia z7b>LYTP3DX=7V2Jq^9ENom0sdc9{E(qa`^S|0;0(gWq<(JWD`Z_u`EGaLz z;nfL^SA7Q*$8py&5#?C%c0`n5r;@`5dK#q0gg#(yLAHU=OjaYmA_&Aefy(Bhw1mBL zAf=-DRf@tq`o9@GDK2fp3#bK-Ht#sP5`;=+Qp)u!id-5ID_65c9&a%JERIg`#UuL;uorC;s}A`|h>WbevV{D;YF4+d`YlQ*3X|AE5NgvKw-% z#A6Nt;Yrp8!{d2o+}#Hh4Vr@@P@NI&F`X!~h@oUOsn|H%3T+*&+0%Ta1!?0KHEMLx zXcb2oAluD{Uwjr`Cf{8qjpby^PyP_>NX52~ku@vUmEPWZq!##B0&@J&JHE^C+@fJE zTzxS@s8AhspR+xCE`;;}3X$KluFlqRKq0)Y!kf&8`hfp2>>ZHlN-Lilzuf;~CG73& zC=?=o`33Vupb{X)|EFj~$iT`;(;hG1E^{6A zUr#0SJsi>ofnw~d&Yv!oV$2?LC-GkM2XL9(#MyLYx^guLIMCIB=w-^`m6}|h%Sb3& zI6yA_NjhgGIofH17(W}pV@~p0$X4*Cg?MYiAnDVo?bR?8qPdX)2rx4!v~I}cJ@J$K zNvaG)c*)j|GK)}KJ_2ti2t`|Vte|1>bAH*pm3mL60YqGQs>NRHZDKdDh=4%}AGJ!R zpYErbLi+B~C7gxYrZ|h|Jd{$=RC1C2@&~%k7?niQBYZY`an?h*BvQ`|l5A4Tzu{a* zt9yaFh>vE=AA4Y;b(5hG1;%Y9B~|;_!FrJp*wQygRxZqM_M>QU3@2K7TVCWaSF>h5 zB)yT+UeqPa4M?fbe>Hd7_rFrur$IhW>$jDPo&*5yo79j|V@686>J!`wQb!OUD8%@x z-aNI?TJsBr*&J*nAt``A%DC-7Qv<7#=n*BJ23Q=c309WH$FvcCz(wW@M7YfgTRa!B zf~0VCIp>2%|0>k+lo;VUIw}(xA3MzeSCWW=&4!#3mV=X*-W*XeX11OLD8vTXk#3c~ zHhXjKI%c?x4LZ2;iI18MpR5fEZLEAG0ZO@7B0CBr(32UdShlYqbv7FPjN;5G-HOq_ z=-Rd^>LdyZLh0EH{*!@j66p?$BS9me(xE}~v5A-L06uAqCXN}?r(Cw2a2(-}c^U$& zkQ8&22Oo)IYc4j6w#JLA+@b`1kU&C0D|0R1us=pA0-uO@%a&&~Xz+p+Oi9y*al!T_ zB|j5Xhlbc>hX5HEdChEE^apzpX$|%QE8+j-b+!sKIo|h#;xOEJZ<%AN1mv&}7T)95)^hC7W`g zO?O!)8NW;-i`2CKKIdqvcSkA z3ZwmBl9A$_p!Ui?oe%Miae(2t5C4dz1w&9%)M z5fx9n*n!tu4VUItVWy5KoO<8!#xy~+4Xd)dR(w<1fSisa$S;iRsxUL=mk0C3Cm#Mk zLi->1)+2Fp*h}c~cnC39K-8*=#16=$(Ltqffsh`EnPE25G#2mR$y4gv566n|n~O$z^!Bq?Zs6!6y4Ac;7lK!S%) zWyHONFG$##wu{@srZC}C?zinf1Q+NKv4~gkZ^T654c#Z4x;nx}kN|mLKykX7nCVmb zwp%d<&vO-K_q2-ad}1PL@VspDW`x*SnpDZFrUxk^&^JWL;{~B;bIM}h4`ewTFEXS_ z7@s*n^30~GoS5P3oPF)G6Ye3u4r*SRYGRE2mBN8~G4bfUd7vA-s29CFiAX$adP=hq zRExb3+^7Aua~U%rewE|tsqVjH$GKLvY+RH$?SedsB3h0ZR`LLsZXqk$2dGVgXkRs6 zyMpLJfdM{v#92s?$Dxr3ggHn`YF+V@%aTyE4fWR~vx~V0{TPsRWrOvl$#0)rh`10+ zs4;`eBWOPsBpr0tXhO_Un{V^ojo&Kb74%iZM)-LBn~TNK*Mfn+EwJn7pFmC;bbpht zVNq@WkBlE*=!{)xqa8~F>!9yar4MV@e-V~kM0y9F#_O(KRvnzd(MN|0wb}QD7i=?f z>Ql-+?RE>T2+fM3!z+Om1Guuldd9L)l64{HUlP7bnX2CE8IGb|EEn*8#}^nX7~^@Q zMVz<~c+zL6hbAbtb4=wW}}%@H9@dWQ$NR7XH$;FNL_{nRbmum8KQ;5bhC zMw7ZB(f^kLQH#_I@TSKj6IKV5N1>vPtcJTdQiPVgz4}!kGIh#<*U*?`gH{S12a5g=zE0g2Sw@rKi_d_KWL&MnbDmN zs~u2$QLsGopGVw0Y3@0C*g5}Hd6oDAKJ>?=G38In!fvr~YZGeTB?ijA+UkA*TzPc2 zH|jtPF_MMZ_c~fgXY51@es^(aWtj9F^(8rpTI!Gyyio&&Z4LvgOr;onDwa9}6!s_t z%r%K=zmw~2cnQxIMmRdeC~mE~`N^~IM0V*3CP}niRStc8Wexs#Y@|Rq0eCHFt#3F2SUT|~mtXKhC_s};2bUXm z7Gj->utX(^Z_~;d-Sj=YZVMuj1(Mq?S*w0rSn26RMZLOwgF5Rs^Nel_p26rvgX}O< zh#aNq7PdQSg5epz&Iq5Z#)D;}DhHIaeP6YGUuNA0|KI)9;tjY7SkTEB@-H3I*s>jZ zkvg&(2Jh3k`l@NW5HM8LI&tF%us;gOjva5B;aVCFSWAOnMMnqc$8f~Gqa6xiJ$1G;4|3zA`yA+N<|I#W`f7k9Cm*dp8bi~z33K_$VfX@IGYKk=Cyv4 zubfN*9KQsZxwLi2N>at}(u-+5p7k3=bdnNM^atPSgoh5WCY#C%0^kvPwl5J4EHR|Z z-ruo_O%+0uqwai*$E)OLy1r`hDO3 zVDGszXP!CdIWcp;sjJH2V3K130Dz+)|5_6Opl|?yte``|UmB{EssKRrgTm`q+8*f# zi=IA|n;F|Dg~bhq>;{Dvy@}?%A?6fzpWEP^k;vFz`WeivE)x_!StVnn*b!8wDF=Uf z$JC41FcpmJ-M0)@{=_uPQ1ysPmBg517EiaNwJbN}--R}A~` zM!W7~`-C!UaazMW>w+Q*-2pEC_Ry3>hh*um@aJe23Oae&&(R1K5;oz_6Hv@`5}~1Q ziKC^9HI2%qzCa-cE_eR8aR2`hPI1=X0>0^A$(w&rq{tYPdwyFFqwo3~FF?-!hW_^T zz`*!&3p4Zd7ibM#zDVBGm-spL>;f_TCpYK%IZX<~N@n>tNIlQK37Zna89R>iro_p2 z$RaL->6_n`TCq)u5{rwLSqE=^+T+nAvbN5j)7F`KOJrB9n^r6{$Zx^U|Gfb-;Vo)~ z6f&2~aP{k}4;xNT=G%yl(YBtV)xRkxA6|1VWoMMAOBw1KOzG-PY0%ifDnHoE$SSFP zZQ+3n)4^kqa<%40+lq1@;^aaKM9`Q-L!N8kbspHoqvTz7e!pZSq6+I5O^h$M*4L(b z?}sKOfXuaJrpUXzi_tnOdU-ipG}PAnh#~IppTwrXPnX}IT%^8fDy3_#kdUAKyd)I8 zr?ObSn5`)E=SyiFbGk2<$*FXFAn;1OY>RySG9B1jyzK4O?;C`RQdWO(lXEMxMr9F@ z6Qj)Sv2qw1`Z;S&dZ(83>*!Jf6?AZJqlfihf(`ACm#X_mRH7oJgF0T5322Er#W)FV zsi#s+1jPb`KX6fYUrx-q)G*~~Cl=k1F7mT;Q%}C3@9K#c5Gx9zzm;duc*?(u>h|ic zj*)ke(C>+@E|B=>thL#x62@U&RtB401$_~N2_;Ev?@vjOUqDG!+qHg?sDGAYz$k{ zfGwHO*pzcRmSN~5dRnP|6f3?8eB43|VV>2JRd%$YD z*SAB4Bq0sRpwnTGi#b_7&uk1qE7%hG{q}ob2}#J@UcczBOvxVW6rZ+s$V3xgwLXYO z%;Pt+z&JTH`iwxW?%?Nfa|?-_O42`VnU`yGyyipaaQcZ^TuyMYfgTL#%rxqY8u9Ls zt8~oP1%Belq3=~CJ>L`t`!4!lrWN;-zEQEn7^R4$7UO-iJi1P6UYA3q4;Btlap+T;ox%iShOi8`qYkoa~nAmSE=J{c#Z|Lmoy4husK*FyR6zXwU zJM)_F>|p8FgH_k^KAHdBenus%XJ16b zr^2zeeqO)GWbAGIuOb(-XPeUX&Sll5?mqrU*A(LGzUx&PVkXP_&c1R77ki0(ny+y@ zppbpvz__`xcBID0$fj9Q?zDF!+0PyA46AEOZSMN!5-y0$9Uk?iDQVIdT;H1y_Z~m; zKC7Bd&>~n{E07fxv2p)t{O2#<$JFouq_HvHOcF(p?;eGZwKElMzaBVY=K8kwZ z|ItG~i^U@_)W<++bESud&;#3;S6Y#Gt`a=K^g? z@a?ahL}6(57ktDLH`jmO`%+KO(9pcQEUz<*A)ZFIlNVHOdUc!yRYn5^d}o3gGQ-qV zOXK_nJG>0et~yTJx3?J-C-yzhCXjy>*O=+85zzDz|0x&eBiJW7X64Kh9w^(wnGACS|rncEZ@hk)x3u zx#|#=nXP*V(%IQ*AC)aJtg2y1Xv@OZ_%HXs^{qEFIgXKZh3f>qlF(is47;KZe#T+cr|H9XlR(tk5ou(K^` z@Re!TDcSYtxs7;d*eWh@KNIk{zy4fBXE#KUW6@IsNfv8W8DJa<$yzD1qy)kmQ=TBQ6U2`5fF%J7b?s^I=4wPPE zE{Fl20S?s+q_`(n*j0H{gk{5bG1}#pmdExaj$8!MEa-$!x3?+Xc(|$-CU1NWc2gT` ztMS{V0{%mEZcaFD&|GEsUG~Tq7&I{w?H)PU{a(*OZKe7h7KAQWEw^;|BzbqoOJt)U zy--nbxNc-LgO9^Z3jM8r{v~&LdH!KybyXQD_#mub(#5(xVvk%qXw`Bc#8O|4X7{Og z&lhAHYsYBDovj)-n)$guqajide8i!sDDhpJ(qgNJJM;L|U|N!%@A+7Mn%)#alfIDB z*Cf^O`;Ch#&_tBHWuy`v8ck1V?5)2S;PtRVXH){8&EdfgJB{zgG@qRo z@Z*GGE(>@pflNo!GS+za;r1pn`?MmtTmEj{$#w4ll8c7=b z+&qL1jvQ=??mMOuBL>ChR$3m!&6elq9}L}Zd8r)?^{#dfN^vHvAXsmJZDVa-zth_k znpQ8SoI;jQ^%dXm4@(R=jZBQMgCPb@E;i>!FtLownJ9Is+ra)r-;FT~3p-{UHUN?? zmx$jOh3>XKd}hSz7*Y1v^_<B_oxRl^T1V&N)rEowmsFswFY48r)LC*L!A$+o zSA2GGymdo?!2V9hFC!42B&6TuGsnJ&!Kz?~@ zao(c2yeOb@T^coUz0$E4t57%eY9BqiznnN6;=79rfsXmljh#0pv1yr0s6$<)fbE&l z$8Y6F63!Kzg9Ed3jNgY%7Hhxln8g3oYkc!`=h%pgmp9w%;%nBK1#7Kr&U{7%pM4H@ zWwK0u+RH!m_bcUelk05=MZBovTdaM@GTzMWR=}7k;Og7F|DyAkmnS@4 zm^mLeB)@w+&Of9nO!KsUo9DOLrSsDnjfW1tt-$;9=-}YC*Vea)Y8f<(!$t>-JAz`3 z6_Sj=IM}k=8p2?kx+E6=>7Ca4i`p7gW`95vA93dFB6PY5`$SFdlqiyfwxMQkd+Sb- z<}rtGR93pT^FMOGI#~aV1c+&HPaGxO*E+R^9<2JHV_9!&?k}&{Hgj2`rGVkKzwwtf#xF7@4;R9+r4ybu zQa9_$95+^>`qKAnrpwD>{$84{pMuH_bRM=vbtmIXK-`!GkdN&SM7d89WpPDmYYzH` zCrN^3Hl^(D`8rb0yW}aiedUpbnN0?;?|z%NL}_{x4WrE2zwr^gt)(nKxZZH!Kw z4p}7ZJ{-XY$zimO;N~3pZ+Ywnn3AxUUU#umrL$s7(!;AfYi!SzT16!IHWyrux@z2D z9?O^lWPqg^TaN$PQ-;rNPsO^IvFu8TU|GY@nyv(0NEtnRyz0yKj;;9e?6~br$S^U= zruWkPr*Rae1shWJoJ_C6r?l2X7i~+H%JZzcy8An&ByLh1n&1|p5LyFd`UNdpC+U+QY4jSVYa1)FSX3->R;X(M%i^=fBT>&)o(#@URai4N4IV*!Y=lnhyK&t^?4QsRUTv}p z{`~EA@xIOw<+|@ILk3T5?`N~V$+P>*16w0oVHu3f%{{>H;p26fnp-e^EzMnmo5gZd zO>Rx3jdHeALvZ2YP3P#d_R)>7NxIFnx=$6$ludoDko)sSB-o;FpGHW1QT}P0(OiiP zwhttTIP$}l#O#gXmB(o(7*nj}=5h9BZc(W>x`Em+*I-0>s;HZGen@l+d$Q|(se21` z7{%V(8>aT&t#BRhQ**F=eCSWR>HX4u%>l<5zb$W66T2MBUcc?G<9it0BKG?OaW|0m z@n7EY_u2_xzeVHt`Siz)RoD~TWUU;C{Ls~`_DLjB{72}=|bT^L`P_shhxpx73t+rVoFAw!3o-4iqVC z&mWt_$9cOqKjnl^*3`xcp^W^S<$FHT)>5<)HmK9)c(AXp16u_rF?aTHIz5Y`aobO< zY58^UC|MDC;JgB9L0|cbSV1p*Q5YB6UKZ8S4A^i#{@H2uuiC zUAyNxiGJ!zS)cg{VQ=QYAk7&A-W(#sM)oGHjH6FZi4fK?SZ#~77D zw+7Q7KEECB$0a4#^byD3!*-v}eM|n{Li78pJ7ALTYsZH1~S7`p=@ zJ|Kp1PoHK+uO9y9N4Uc#-&{lUhX~e0TZM0qDIR)BY-IwD{64UojWV;FV67aE{(a$h zS!TABWX}ZQRA@BbjK#e0ZgqX7q>}PagFdaQ?_eF&NaI{^v^bw8`Q{c%{T#!2I&m{= zX(`>u*;MmxfIj55OMEgHUg!A&9>P@9NFzKUYN@o`D8PA#m&9*%O915WNt#UgOcN=^ zm7j-q{@a*iLbC9&l^6h5?tECxW#-q?Rc?}ipIyI8T`Dr|vP5mo1%1*?q=qm*!NUN( z+@DJV{A?jlI(T>Ptrd;<#*Fo;3`L^48;VC7Lx)wFiF0=_V=qY*uBz|lX#2Vb4{2xF z29i)tN|ygZz0Uri?cU@0TtXh_if1hT)!W8XFgffc!Mjv>+<7MVJT+#g1pZ!q4)QZ? zs#%TNEuHIkZu1tWgK0D83P~ly<65xJbj>_2k#DZm+R_VAobkSM9r&K25S*RvasKd` zw^~pfr%8Rz?qizB%pT@3k!<3Tzkl~-ZSh7J`0NQ3Vc-hY#Xy^kg6(+jXS8jwrWw9$ z_2a!0L-NaeIVR|^Gc)mi9&c~d9rfuZff`)DxnA-As9|~0?5^H$S^uOo!?s_$vy=(q zq?b~Be=)?rN6{b0DN$Xxog#yj(t_-yp@4e4SJ)16$;%r>kV}o~+@1c=uDB8vpH7QT z3+T-bVLX-?7lN5a{Uifz+?dkOvX~x{tle-j%QZ!vl+)4-Dz{C_K^!TzGWFJ)s@!5b zFD^FAWY~4*Xj#nHuu`aIf<6$XYz_LYlMqjHgOpAlnxn}e(>_3;JEVPLIZHu-5%F8qggEDz9gzbi0`hz*_s>6qaOk0Eta_13YcbjNq9Fr}-b3G0{nxxzK19)uvQ&sfeAuGf{>;evuDHV2@`Z|=nliC|JqTRJ*7ydDRxv*L zt{--78zVKMP)LZ}fT#1COslAH2G~t|f09YF*Ds|(i29#0YuLS9(tK!6mN3t$zlr3! zt~Y!QX9Y}8TdN8MZ~DM$zOlBpVUiliKP)RPH8Qk+!SoPC(g+i!8u}<1ttU&40Q+5> z54#?x2ALrJqAj(iHn0L`OiF73SLF`Hsb#d>%6r59Zg!^66E{oXVk}+26!zY5rkDs{ z{);kXq8l(!>xDo$YBAwx6Y(W@OJilI1vI|xakp9Gp4z<0xNp7_nE{gLe~Rjc=RP&v zf(gO2fUac&4PfbLY_{2aRCBO;HkHyFVj`5V<43C&$^pO1AiYU?c<9xW_^9zvN+R%2 z`z6=C+b%qN{aBw(*yUC0N0*NLEkt1`D;zX1v`-nzEtT)AJGaXWb~If9kE!jY#`o=- z^DH)aV&1XqvFh@&g#X~4cYE*Ta*GD0jjdicTm^i1tVL$U~PFFy}-{<^S-4M8pp3?UiD{<{H zt-h-tPZKDnrxri9#VTJRA^t4-ylaoJwd-bl7<5$2qKq^Lg}>*Zn}YG+ygygN*K5D9 zNb%v%ov!%}0nqc!d#Ucd!RC42$`1$Mo1_ep5LEi7(C6^hy1Kn%#JQZR0fE2g`feYO z?gO+3)O`iaK@^uZw!zNU)uodq*Xhs$;CJ!y`bGH#Bdq?cGM^%?gl7>CsCU~r-Ie@3 zP^)tOHNcAwFjy|gY#3(Ziyk)>R>9o~j zz*97xiT@mycPqb}9%tC~-5;^tFl&N1P8i8@ISg{6bNdVcqYu%GDqgtY>;r(7+4 zyr%yZr-fzaprq`4 z?v`ETkzBIzA@{mRGn{xhFJL$OllE-)DCz{}3inARhH>R$ZXVYNGF)qf^rko%O|*z)_qGh;S|KTuFC3#@cZm(K-?ZKPW6ifetI@_}fh ztomYh&%up=s<>94!sX6O1kx!5LM!!VBupISl*(TLG9*BJdckfbo?|t;g4YMNtN1R| z(d5>9)Z-|VL+a#o6EAJp_s2G(!GFuhaRWD{lgMVx;_1udx}H!G{#jBtK%G89?bj~w z`mD%ag{U~Twe>&Xv`|_{qGqs`VP{qF7B%jc6p_Bc4fnrxQ5#66&idCB0b4Uw3wVI+ zVImd5<#SMRW;I3ZsSP)ud}rsh~@zj}3s6zIFY z-yd+(qX#^#&!^C3 z&Ge-N0%B|HY4)9YYE7aO{Tgk_jZMhMS683*w$7hl4EC1@xa|H1l>h$dd9k%QKFqgR z{rF9lYm4LRe>Km3SqYo%6EJxPGb4vmb=Dh5CG*@F7T?-pRa8Wd-EO8!?x}#D+5m}l#$%RZ|o&{m9 zlpRZ2ok*l>|HD1irR@7u9sWIXR^BBXfN}3ne!}$6RFj2w7;WI97?L2M_|2YK?zf~^ zeE+7PSs0Lcal+2t+ejhiZWpi0WyzTYxj(kGiiMd>Ys`;UFo*a{0E7u(V~(<$P~!eJ zMOhFDR~Tz0;M>f&oPFo8yU9}gjwNs$EP8-8Am(fC;q-a&J2xvRk=5e-hS$#4e|iK| z*3Fij*f^$|NuT#;R?Fz#dvA4VmuCpD({zSZn~^2ywKJA}-wUX3P)0S%;bOahde93( z?!o@8nFjrd%f;HrRzi*NO&lb<$KJr8(aV|n#D&d9?DA7q*ukr!o>u@x!A1GqFca(s z%+Axjkr6K#=p3Zt(q%AtC97lh#^YX1-{v(>+*!2FLo&7F!~S%Wb^UsB^lSacv&fq6GS!p3iLl5*WaL3yh~MU z;*Iy&jKmpugdW$33d|_}qV{+HdLiFq{s?Kh+V}t)ODqJt+Jme%t|QL@W&B|iAK^Mt;yGO|VSshv|Y^k=z#PZ}nz(0=BdEU#2C2?H?gJFOdj>hz!Gd;bm zB8k!YF1U;B3JIKK*N=u7>@g(OmL8LH@42c%z zM0_P;-x)^HWD{ap54?b@D2aU<5%}REx~T^^Eurn-{U`p(smI)iEiYLOX!UVn@I2eT z#k?riajUl_0OD&)bSK$W=g1qZ*e?5u{<}&Ie9oT*_??^jpY|JZ{?Pi-3Wc8$8Z=x$ zjp{Z=kseYUf*l8i|0OuvQS$;q4+vSMe;e_*k(_*YadP~ZFkinS#T7Ub5&Q9C(DH3< zQ)PbeVmW{)sHwfL+)VhE)L?xe!4tpfcRiAhbUiy1vymiNZj@eC3&2Abnx-y2e1e~S zekWK5a@oYvUoy2OZK9p#5)4y7em(WFVWWL?`7aqwx;Y{F_!cn&>~u1aj60f`7!YA@ z{FVh)Fed5TT*sKJt-mdX%o1UZZ;>M8jIdVYuDg6uXumIkf(Kcqju{3+&>Ps%#9n&+;Nw|-SCdOA*;HLroYpi+ao(f))pJuq`cCPY|m{1H|$x*(%tr2@UhaR9B6va6kvM zQ|c){$_B@-s?2S#H8mF$b&*2n?*GU0xH!s;Ca6R=yPt?@A&8%E7fq7hVm&xK5rQHE zJn*aZYG0GapZS$w~<0nFwr^{4IF2K=WcPZ79XzxE|sLY&+kDI8X?~QBFVocu}`*5 zHJthgU;s@8H3AcFsg#?reC&e(zmpF6QwTOi8gY<592s!cE9;|3Vpjw+v>Dg;6JTt0xg*}H1c;VIcF;HS*rbXF9bExLBW2V`QbU(AYkTQ&fE~og&h$MWPcu;TOVii@ z7W2defeV%vmw))F{ftwNzU$S{dOI>bkJ8rAqF;vP`!0jwpX+ZL|5NgLp?q!?q!WU<7CyYFKYM+SAhaiw#6JJ z@-Khxq>cRfPaGuOJU)oe#G`@T#Tve-6Cj1FApO&jQnAG)FH4-o_k}TVHQ$vrzt@j7 zNeUc?MRwN8m6)yJ`9*WUL+r#?B5&1q#Q}KG2iSY>YHF&l<_*tG5}3ePZ%5OiJFQ1p z%nhuN99+unY3x?qcatFs7)3(BM^k+G2q4b`q>gxfvE%+~ z{dtW@9&xP#u;M%UU8+4tYR%$xU4z_T4d^IP=<-=wTa z_~ZU>P#P*F<}a80RsaoEwF*$D1;qhwR`oY_JnsLTrTBJB6722fVs# zOtj3EDb`;Z?HKV|nYWQ<3h4^p&w;Vsn;5XaMzsWM*qt9^`APG_a(s?zlQ-O^E zoDK=2QGQ3;C+U?Lsf{wO*+d4>%2BK<@tBJZEcHZYl0j2CXRonb0UpPH<4!{g2X+?=-*nXoe!V?C(O<_2fDukI|%` zYkgNRUt+%kMTLU1%|ygaVe;6o=DiVw;7{L$yb-AX{}%}-6mt+kb=qRz5$-O^3Q6-_ z2rn8ofU@=%vUd)DSbYn0@9T^ZzJo}$Arl5?Km5JNudYHHz~=*X%RSWLCH;IEbS7>Z z_nZxIl0!W9M8vc{M*Gp@{u`8Q=;eouQfA-Pwhut5js*u`)N8;|9a$A+__c7A=TQHiw(;OPOu^`sj9K1SVaX zv9VU;4`rPlWF#rN52*q?+z-fQC2Uy89#HS)8(^wBS-=;XUtIhTy*F&c<8I<7JU@yd znFt9d2Xw<&J4PU}^CchP6tW(GT`od`*kA_;F{pba+Fa-X;H?yZ1n;M;^22_0hwO>~ zfZuS?c>oStI0SHdbxZdFfE#(x{_oKW4|V-xydQe1I}$*M4WixwV)8p2xGdbEb~>$F zsALmq1Jph8%Kh{3K>v6*GJjGU4jJ%<0+{dT_^X!^k}don7H~RzQelBUCmD-0b4lUDgarRwA@0))dy>aAeUkynUP&h^|LEsK zZ;hM$@&mvuWJSa$tKSlHBC7Jw2`V-^>I#r%H13A}H>rhuOXnL(V1-Fw?U;RDj{R~C zfJ;C3eHb7{giC{1k_E@y$DUZhdZ&;q*+Cyj$Zc1`o6)0$*xiY*kpQQCA9YRtLrAQl zn+9h?vYj-ktB@;FANVq5SfGiKXaaApJDuV9pI&{)NP~frMBztPh>hOK+$8 zH^n=y$9>nKqq-dCUe2O+C|t~0%oM}4)!DC=W((1`VE0o9ca6w=Kp0i~sojnx%`#ZvnGDkR>`f;4%>+^qm;htnBHK)VKGP!$;%*;3X=cbQuPX!7t@Hn1(_ zJ^(%J-L453rv8dg#)bbD7DDjht1@K&2P$X+v>}?hfL6dJ7##Hz?Zr*IL-+xNv=DW% zo%D_xSx9zVVZaXPow$rwa7Kk3Xnz)u-@eO)^f(48Je>=>{XYbU04fK#@sVogiLx)i zfQvX0%D>w)syc!^16d3yvYUl*q=kQ;nrj^V_gT91*WY&FET1UNxd684^>96d#1y2< zQoF3C^l=1e-)z_+v~+Z~!2B{9DoAix>mJcB!9B6KTM|{>Hf1CLUvhn>sF>}`W(0^b zy+}qyg$Fq)1EPxdD}ub?etG{-HBT2YX#>c zsLu9VI0NtFzB@0P({5&b2*sGJqq4>F+0wH#yJ1yz!k#W2+q`!T`BK-Z;F2 zj|&(Gaz#_z%>qR%xxp16V?{^X=tB1O!_5td!(sr_#E-_?=+b$q_Eb$~+yoh*hK$`v z<0nEvj)MYlBF5@0KAALWR$cfFtU<;{ERlQ-L+l zWBC3SC5R&mbm&69-WCt=c-42h46F}3B@ah`H6u?3Pyw{=z?k)@Xq!w(`n^V^Jqv`^ zVD~Z(nM-r9idjmB3>@}cv!OK}Mi=4P!cN#JkoAg%80g5y#YBY`8%4b0PO}0EjqsI0 z^Ge5)=_`-!y$AD4$WLofe<7OSfyd`@U_rdDt)%!1KycG+3n@^^3p}|8Gbls`va@tT zS6m~zO=%IAaXlYi(F{_e8Ko8|fDS8`yodvLfIzxP52(QOH1~9L*wZfEf+k zQOhA^E}(VPEn`wHxn!Y9_H%LRSQLP|!WtQ3qOQZz`X?N0h(rb!XsP9(0_8l|?RtkDMe{NU0&prU-iCt8G*hy7@P$!DqDP57AMJEBO#?Qdbfm`J{_{#qKr92h_4j}ez&1yrj z1u9g-5CEc!=Ootm_woJGsXpJ)Oza>Rwm>1W`k1ERb?#l5XTKSs6q{H4Zw}mU88*_q zyRe)7qskL0#N}Z%TNpN~dJdmT6&ve`W^A4me9dYAwsd8$ApSz=h6)>Cq^o&CjM1~H zFCoqR5~lziBfsZlMRD;HNpgww{#$Zv)Hv8~!y)q;8vK2tq0FwwPso}2S77T4D!>?F zh8BZ|;)N&7q@+B~ryq-)O-Rb36qWoSt4hcW!i|Y)?>L*>oQV&FMq3zgTOH`Y^W}QJ z{fWq7Rz&<__~t7N5DIty!AYd13&AA$$0vQnd@5#v9{|~YuJHH_!OtK4V^~p2>D384 zowc3Vh*`~%0Mkp#owQqIB;uTrAfHIp3hUI=H>gO=A`OR>Ic7Wmxs;4R(hVc>t%opW z0H-7igI~#|cSc<+ye@-5`F5cMrnKZzqm^u|K ztSrk1=m}9v#48ebg{o%TJ9CQJnU)ejH&00fzl2Aj>} z=;+M45!`I~0a$d}lLr4$MZ6LIi7y4o8}iA&#Dx1WhXTaX@d*ixt>CunC;*sJO2TOi zCqmHEJbkEsY+m*QePP*N@VnpOF_&L-!{Jx)EDf$s0)+HeslGfVHexdno6Nk(YL?I( zWT-<8nxEEd2%Bafu2$V^H27QRiPSufKH#+YH&p1Lm_Zj6si%Iz-e2GS3zW4CoR~Q@ zr$8CCN|q5$Ip(&=eLc`XPiQiVQXyaW@*De0$VRAjYLQm!Pf_Vr@R%+veq3-ihd@fT zgHdCEabOwy-+%iCq4I zK?(qY@_6{`=e>Gk5+Z;*c=nA&$Dr_%mDTmF`8xvm?`orN(b^=m&{sPs6+IPPEaVaY zPAoeEW#yQO@^1@3H6k-|*2$D5Nsmjf733mwqD@EYbJ^7y6#3z@4H-EGl-SoP1;WFI z=*Z9jz+#wLlaUhKeNae9D6{Q65CZ!)d{@A6W6`QWST%-g8sp$x0YYm$4e>}T5UxWu z?d1%G^`bfX*wc_L<)zQNfmMqiE4CSt_%b_RTY1Gr$`gMlhL1QMDh zxLj%7Ps^`j7KNLg*ZF>^xa7Ly*-n_|#;m*joD2388d5&1TX{*f5`KW@>ZYK>oFBNh z^jRQ2v07B6%O9Zti|2@Hbsxu7bGAfMHykVA)X`&hqsB9hc^BQsg?-y5b)w|`hjs_c zG5Wbi>p9B!BdM#VpnCNf^(hsN=cf_{Dcl=oZ_QylE)`yaZ$g@6QruskJVeIyGzdSX zezCe{hOcik2@l~BOTW(5=sqU^+;L|nImI4!Du}7;+$G<@;3+o)qbL9FF+j30pe*u36 zh3KI7#{6z5GfM0vC0Pl*e!o1C8eYvS2S>a9fhnR>K(3^i8vx~1;Ux}BDM;~&i6~pK zZ}GD#Tztuz*%xmCL3svC%1Ujq`-tLr=7uN4afV1zG$4+41l>>dk_hziKj!#iQJ~qP z_gC_%C>>mAdSGm*6%4loNJ;7FjFDu@SqI{8C%sUJb&3b$hC?co)*q>%{89uy=r>Y2 zHxpQ}Y&5%Pl7($2>d_1eIy$>t0dK$2V5g?GSXfv@9_#{m^ze)^T{BENZ5{CP_?Lj7 zF+TPSeFt3{LL`bs#qaa6&61W^{dWjuCrNqGBE zTlSxOT%T{i5Ys_?MYiIz}w&cEPA+AwDm~16&e>sibQAS7zr)hiqU@dt7#Q3 zZAo>S9l9bpgbo=Z=A9zZqt%R&*H@hi|4vUE0AvQTX&JB)q7r;GR`dS-qJ2n;2kJ32 zrYna9Ba)N>M*X&}{Nq{x>nNrMZltwmex zbdkWTw?h%iVH{uZPoUP|6ed%vh@`*r{V)Bwmd^|dC!`!cC2-H4>NVAhdZG#d*~!mh?v-Ih0Hflo z0vR8al5jW*J^XB_lC6DYoY)FOpyKtfKAuI^FY1$0vXPq3)3z?asD2St*p7h=V|3|6ol1tt z2KdcVukaC_&rO)@N#7K_vNlLur&@ywVw(^gom##`if3SvxO#OCMUetXlJ-jyI_h1L z;W;CsZIm4(xmHEO_&+(JnBWEZH@vDt7^oQBqP(h3=!!T%5;&%<0QbN-7f*_Fjvjt9 zyyV}v5oy%e>tpg2Y)aGo#>4u`_}%E3D=;o3M^D|Ag6!Wq=TUAc9@M@Ndp@l1r+1-jfe;m*uR8_oU=&w|6 zAHgxxbwuitg|Eo~Nfib|ml$;MXYy;BRtAaACYPdM?uATBuxaMdj-#=^FZqWBE_Jjqq%y>rqR>EfH67Xu4n(nw`T-q6R~X1y6WGj7FeWK(Y%X{vk=BtpE<%l#)i&BZN+rFZ}_N` z$dIzrRS#AHAR1g&TB-@*3n)#Yz-3LxT4H^{XZciKOHXIR6aV>+a@X5!mH47FM=TFg z2sYi9uP?q>le-=ecPqpa4r3d{GRzh}n6-1Ujq{aWbGj-m{O*|WzF+30i9l8tLpK#- z#B|=~li6|Nh#F?B-2e4j_{p;~{7EG0B=E~MzVv$`y1D~o^#Snvx;Xu4^15hAMG;Qw z{zQDn|AbGX=wtDFjpseP?cd(#V)U0oK>p!2`nI$r#>8JGpLcp=Qpt#6EmC%tt~MTE zV~|+!B|6r-uKvM4{OaQ@9f1@jB-}GJ=_0kD=6L8Nd@R`cw0}ZgX-HKrax0HSl-?kd zI`C>cPYAw0bB2DF#~g}3UjLpnxZ-Czwr=4|DHD#jnk%ut0n0Z4z2I6*+}Dh=e1!sw zZ_tl1SVul{p=Xnp`awygn(ygzIT)6f40B)ul6 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 3dba8321f2de1578b020b1086bfceb12a8fa29e5 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 26 Aug 2025 10:37:56 +0200 Subject: [PATCH 032/161] 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 39809e88b80b9335a64a6e8cdb834acbf05f75f5 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 28 Aug 2025 11:16:36 +0200 Subject: [PATCH 033/161] 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 8c226b66c69d0aa984dd8b2eca98fa4bbaf123e9 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 28 Aug 2025 11:17:06 +0200 Subject: [PATCH 034/161] 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 e0d65b42f418f07993ecaf2dd5dbb84280e4d4f5 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 28 Aug 2025 12:47:14 +0200 Subject: [PATCH 035/161] 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 db5427dc3..e43bd9595 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -6,6 +6,7 @@ import pytest from bec_lib import messages from bec_lib.messages import _StoredDataInfo +from bec_qthemes import apply_theme from pytestqt.exceptions import TimeoutError as QtBotTimeoutError from qtpy.QtWidgets import QApplication, QMessageBox @@ -25,6 +26,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 2aef05056ccdefb539f4c27941a73b0f10b65486 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 28 Aug 2025 12:53:06 +0200 Subject: [PATCH 036/161] 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 335641e2f..d97e46d4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ dependencies = [ "bec_ipython_client~=3.70", # needed for jupyter console "bec_lib~=3.70", - "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 e43bd9595..6eb093e93 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -27,8 +27,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 @@ -42,7 +41,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 fa642aaf49f6866a7a2317dbaff47b25a6eaac2a Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sat, 30 Aug 2025 08:54:03 +0200 Subject: [PATCH 037/161] 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 6eb093e93..bfd7ddb64 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -8,6 +8,7 @@ from bec_lib.messages import _StoredDataInfo from bec_qthemes import apply_theme from pytestqt.exceptions import TimeoutError as QtBotTimeoutError +from qtpy.QtCore import QEvent, QEventLoop from qtpy.QtWidgets import QApplication, QMessageBox from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -24,9 +25,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 c72789c8fdf7114ae348ea6c5089da5017b36e17 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Fri, 29 Aug 2025 15:39:58 +0200 Subject: [PATCH 038/161] 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 66a29d1f0745ca3a68c68fce6d17d50401f91068 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 1 Sep 2025 11:19:05 +0200 Subject: [PATCH 039/161] 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 3a78c1b177c564f2b7b6a4707a3eab8e62d6bd51 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 4 Sep 2025 16:30:59 +0200 Subject: [PATCH 040/161] 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 67d50d7b68ed74dc98110f840ad653e651201e78 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 10 Sep 2025 15:04:57 +0200 Subject: [PATCH 041/161] 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 c20bc00f452d2abf01e15536a8927819b02a63ed Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 10 Sep 2025 21:12:50 +0200 Subject: [PATCH 042/161] 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 6f78c914b7dd8b10d318ba5d1bf6f7d4fa7fa0f9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 11 Sep 2025 15:44:47 +0200 Subject: [PATCH 043/161] 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 5895147c005da737785f1878919aa72e6860367c Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 22 Aug 2025 07:55:33 +0200 Subject: [PATCH 044/161] feat(dm-view): initial commit for config_view, ophyd_test and dm_widget --- .../examples/device_manager_view/__init__.py | 0 .../device_manager_view.py | 206 +++++++++++ .../device_manager_widget.py | 73 ++++ .../components/device_table_view.py | 84 +++-- .../components/dm_config_view.py | 71 ++++ .../components/dm_ophyd_test.py | 333 ++++++++++++++++++ 6 files changed, 743 insertions(+), 24 deletions(-) create mode 100644 bec_widgets/examples/device_manager_view/__init__.py create mode 100644 bec_widgets/examples/device_manager_view/device_manager_view.py create mode 100644 bec_widgets/examples/device_manager_view/device_manager_widget.py create mode 100644 bec_widgets/widgets/control/device_manager/components/dm_config_view.py create mode 100644 bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py diff --git a/bec_widgets/examples/device_manager_view/__init__.py b/bec_widgets/examples/device_manager_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py new file mode 100644 index 000000000..d3950dca8 --- /dev/null +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -0,0 +1,206 @@ +from typing import List + +import PySide6QtAds as QtAds +import yaml +from bec_qthemes import material_icon +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtCore import Qt, QTimer +from qtpy.QtWidgets import ( + QPushButton, + QSizePolicy, + QSplitter, + QStackedLayout, + QTreeWidget, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.control.device_manager.components.device_table_view import DeviceTableView +from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView +from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ( + DeviceManagerOphydTest, +) +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + + +def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: + """ + Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. + Works for horizontal or vertical splitters and sets matching stretch factors. + """ + + def apply(): + n = splitter.count() + if n == 0: + return + w = list(weights[:n]) + [1] * max(0, n - len(weights)) + w = [max(0.0, float(x)) for x in w] + tot_w = sum(w) + if tot_w <= 0: + w = [1.0] * n + tot_w = float(n) + total_px = ( + splitter.width() if splitter.orientation() == Qt.Horizontal else splitter.height() + ) + if total_px < 2: + QTimer.singleShot(0, apply) + return + sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] + diff = total_px - sum(sizes) + if diff != 0: + idx = max(range(n), key=lambda i: w[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, wi in enumerate(w): + splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) + + QTimer.singleShot(0, apply) + + +class DeviceManagerView(BECWidget, QWidget): + + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + self.dock_manager = CDockManager(self) + self._root_layout.addWidget(self.dock_manager) + + # Initialize the widgets + self.explorer = IDEExplorer(self) # TODO will be replaced by explorer widget + self.device_table_view = DeviceTableView(self) + # Placeholder + self.dm_config_view = DMConfigView(self) + + # Placeholder for ophyd test + WebConsole.startup_cmd = "ipython" + self.ophyd_test = DeviceManagerOphydTest(self) + self.ophyd_test_dock = QtAds.CDockWidget("Ophyd Test", self) + self.ophyd_test_dock.setWidget(self.ophyd_test) + + # Create the dock widgets + self.explorer_dock = QtAds.CDockWidget("Explorer", self) + self.explorer_dock.setWidget(self.explorer) + + self.device_table_view_dock = QtAds.CDockWidget("Device Table", self) + self.device_table_view_dock.setWidget(self.device_table_view) + + # Device Table will be central widget + self.dock_manager.setCentralWidget(self.device_table_view_dock) + + self.dm_config_view_dock = QtAds.CDockWidget("YAML Editor", self) + self.dm_config_view_dock.setWidget(self.dm_config_view) + + # Add the dock widgets to the dock manager + self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock) + monaco_yaml_area = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, self.dm_config_view_dock + ) + self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.ophyd_test_dock, monaco_yaml_area + ) + + for dock in self.dock_manager.dockWidgets(): + # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea + # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same + dock.setFeature(CDockWidget.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetMovable, False) + + # Fetch all dock areas of the dock widgets (on our case always one dock area) + for dock in self.dock_manager.dockWidgets(): + area = dock.dockAreaWidget() + area.titleBar().setVisible(False) + + # Apply stretch after the layout is done + self.set_default_view([2, 5, 3], [5, 5]) + + # Connect slots + self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config) + + ####### Default view has to be done with setting up splitters ######## + def set_default_view(self, horizontal_weights: list, vertical_weights: list): + """Apply initial weights to every horizontal and vertical splitter. + + Examples: + horizontal_weights = [1, 3, 2, 1] + vertical_weights = [3, 7] # top:bottom = 30:70 + """ + splitters_h = [] + splitters_v = [] + for splitter in self.findChildren(QSplitter): + if splitter.orientation() == Qt.Horizontal: + splitters_h.append(splitter) + elif splitter.orientation() == Qt.Vertical: + splitters_v.append(splitter) + + def apply_all(): + for s in splitters_h: + set_splitter_weights(s, horizontal_weights) + for s in splitters_v: + set_splitter_weights(s, vertical_weights) + + QTimer.singleShot(0, apply_all) + + def set_stretch(self, *, horizontal=None, vertical=None): + """Update splitter weights and re-apply to all splitters. + + Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + device_manager_view = DeviceManagerView() + config = device_manager_view.client.device_manager._get_redis_device_config() + device_manager_view.device_table_view.set_device_config(config) + device_manager_view.show() + device_manager_view.setWindowTitle("Device Manager View") + device_manager_view.resize(1200, 800) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/examples/device_manager_view/device_manager_widget.py b/bec_widgets/examples/device_manager_view/device_manager_widget.py new file mode 100644 index 000000000..98e34fefb --- /dev/null +++ b/bec_widgets/examples/device_manager_view/device_manager_widget.py @@ -0,0 +1,73 @@ +"""Top Level wrapper for device_manager widget""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + + +class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): + + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.stacked_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + self.setLayout(self.stacked_layout) + + # Add device manager view + self.device_manager_view = DeviceManagerView() + self.stacked_layout.addWidget(self.device_manager_view) + + # Add overlay widget + self._overlay_widget = QtWidgets.QWidget(self) + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_overlay(self): + self._overlay_widget.setStyleSheet( + "background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);" + ) + self._overlay_widget.setAutoFillBackground(True) + self._overlay_layout = QtWidgets.QVBoxLayout() + self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setLayout(self._overlay_layout) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + self.button_load_current_config = QtWidgets.QPushButton("Load Current Config") + icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False) + self.button_load_current_config.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_current_config) + self.button_load_current_config.clicked.connect(self._load_config_clicked) + self._overlay_widget.setVisible(True) + + @SafeSlot() + def _load_config_clicked(self): + """Handle click on 'Load Current Config' button.""" + config = self.client.device_manager._get_redis_device_config() + self.device_manager_view.device_table_view.set_device_config(config) + self.device_manager_view.ophyd_test.on_device_config_update(config) + self.stacked_layout.setCurrentWidget(self.device_manager_view) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + device_manager = DeviceManagerWidget() + # config = device_manager.client.device_manager._get_redis_device_config() + # device_manager.device_table_view.set_device_config(config) + device_manager.show() + device_manager.setWindowTitle("Device Manager View") + device_manager.resize(1600, 1200) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index b541916b6..40f31cec2 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -23,20 +23,14 @@ class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): """Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip.""" - @staticmethod - def dict_to_str(d: dict) -> str: - """Convert a dictionary to a formatted string.""" - return json.dumps(d, indent=4) - def helpEvent(self, event, view, option, index): """Override to show tooltip when hovering.""" if event.type() != QtCore.QEvent.ToolTip: return super().helpEvent(event, view, option, index) model: DeviceFilterProxyModel = index.model() model_index = model.mapToSource(index) - row_dict = model.sourceModel().row_data(model_index) - row_dict.pop("description", None) - QtWidgets.QToolTip.showText(event.globalPos(), self.dict_to_str(row_dict), view) + row_dict = model.sourceModel().get_row_data(model_index) + QtWidgets.QToolTip.showText(event.globalPos(), row_dict["description"], view) return True @@ -47,10 +41,10 @@ def __init__(self, parent=None): super().__init__(parent) colors = get_accent_colors() self._icon_checked = material_icon( - "check_box", size=QtCore.QSize(16, 16), color=colors.default + "check_box", size=QtCore.QSize(16, 16), color=colors.default, filled=True ) self._icon_unchecked = material_icon( - "check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default + "check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default, filled=True ) def apply_theme(self, theme: str | None = None): @@ -128,10 +122,9 @@ def __init__(self, device_config: list[dict] | None = None, parent=None): "name", "deviceClass", "readoutPriority", + "deviceTags", "enabled", "readOnly", - "deviceTags", - "description", ] self._checkable_columns_enabled = {"enabled": True, "readOnly": True} @@ -150,7 +143,7 @@ def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): return self.headers[section] return None - def row_data(self, index: QtCore.QModelIndex) -> dict: + def get_row_data(self, index: QtCore.QModelIndex) -> dict: """Return the row data for the given index.""" if not index.isValid(): return {} @@ -169,6 +162,8 @@ def data(self, index, role=QtCore.Qt.DisplayRole): return bool(value) if key == "deviceTags": return ", ".join(str(tag) for tag in value) if value else "" + if key == "deviceClass": + return str(value).split(".")[-1] return str(value) if value is not None else "" if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"): return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked @@ -436,6 +431,8 @@ def filterAcceptsRow(self, source_row: int, source_parent) -> bool: class DeviceTableView(BECWidget, QtWidgets.QWidget): """Device Table View for the device manager.""" + selected_device = QtCore.Signal(dict) + RPC = False PLUGIN = False devices_removed = QtCore.Signal(list) @@ -508,10 +505,9 @@ def _setup_table_view(self) -> None: self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn(3, self.checkbox_delegate) # enabled - self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # readOnly - self.table.setItemDelegateForColumn(5, self.wrap_delegate) # deviceTags - self.table.setItemDelegateForColumn(6, self.wrap_delegate) # description + self.table.setItemDelegateForColumn(3, self.wrap_delegate) # deviceTags + self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # enabled + self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # readOnly # Column resize policies # TODO maybe we need here a flexible header options as deviceClass @@ -520,13 +516,12 @@ def _setup_table_view(self) -> None: header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority - header.setSectionResizeMode(3, QtWidgets.QHeaderView.Fixed) # enabled - header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # readOnly - # TODO maybe better stretch... - header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeToContents) # deviceTags - header.setSectionResizeMode(6, QtWidgets.QHeaderView.Stretch) # description - self.table.setColumnWidth(3, 82) - self.table.setColumnWidth(4, 82) + header.setSectionResizeMode(3, QtWidgets.QHeaderView.Stretch) # deviceTags + header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # enabled + header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) # readOnly + + self.table.setColumnWidth(3, 70) + self.table.setColumnWidth(4, 70) # Ensure column widths stay fixed header.setMinimumSectionSize(70) @@ -538,6 +533,8 @@ def _setup_table_view(self) -> None: # Selection behavior self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + # Connect to selection model to get selection changes + self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) self.table.horizontalHeader().setHighlightSections(False) # QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0)) @@ -566,6 +563,45 @@ def on_table_resized(self, column, old_width, new_width): height = delegate.sizeHint(option, index).height() self.table.setRowHeight(row, height) + @SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection) + def _on_selection_changed( + self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection + ) -> None: + """ + Handle selection changes in the device table. + + Args: + selected (QtCore.QItemSelection): The selected items. + deselected (QtCore.QItemSelection): The deselected items. + """ + # TODO also hook up logic if a config update is propagated from somewhere! + # selected_indexes = selected.indexes() + selected_indexes = self.table.selectionModel().selectedIndexes() + if not selected_indexes: + return + + source_indexes = [self.proxy.mapToSource(idx) for idx in selected_indexes] + source_rows = {idx.row() for idx in source_indexes} + # Ignore if multiple are selected + if len(source_rows) > 1: + self.selected_device.emit({}) + return + + # Get the single row + (row,) = source_rows + source_index = self.model.index(row, 0) # pick column 0 or whichever + device = self.model.get_row_data(source_index) + self.selected_device.emit(device) + + @SafeSlot(QtCore.QModelIndex) + def _on_row_selected(self, index: QtCore.QModelIndex): + """Handle row selection in the device table.""" + if not index.isValid(): + return + source_index = self.proxy.mapToSource(index) + device = self.model.get_device_at_index(source_index) + self.selected_device.emit(device) + ###################################### ##### Ext. Slot API ################# ###################################### diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py new file mode 100644 index 000000000..155f82d96 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -0,0 +1,71 @@ +"""Module with a config view for the device manager.""" + +from __future__ import annotations + +import yaml +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + + +class DMConfigView(BECWidget, QtWidgets.QWidget): + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.setLayout(self.stacked_layout) + + # Monaco widget + self.monaco_editor = MonacoWidget() + self._customize_monaco() + self.stacked_layout.addWidget(self.monaco_editor) + + self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config") + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_monaco(self): + + self.monaco_editor.set_language("yaml") + self.monaco_editor.set_vim_mode_enabled(False) + self.monaco_editor.set_minimap_enabled(False) + # self.monaco_editor.setFixedHeight(600) + self.monaco_editor.set_readonly(True) + + def _customize_overlay(self): + self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setStyleSheet( + "background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);" + ) + self._overlay_widget.setAutoFillBackground(True) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + + @SafeSlot(dict) + def on_select_config(self, device: dict): + """Handle selection of a device from the device table.""" + if not device: + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) + else: + text = yaml.dump(device, default_flow_style=False) + self.stacked_layout.setCurrentWidget(self.monaco_editor) + self.monaco_editor.set_readonly(False) # Enable editing + self.monaco_editor.set_text(text) + self.monaco_editor.set_readonly(True) # Disable editing again + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + config_view = DMConfigView() + config_view.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py new file mode 100644 index 000000000..a2a44f42a --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -0,0 +1,333 @@ +"""Module to run a static test for the current config and see if it is valid.""" + +from __future__ import annotations + +import enum + +import bec_lib +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.widgets.editors.web_console.web_console import WebConsole + +READY_TO_TEST = False + +logger = bec_logger.logger + +try: + import bec_server + import ophyd_devices + + READY_TO_TEST = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + bec_server = None + + +class ValidationStatus(int, enum.Enum): + """Validation status for device configurations.""" + + UNKNOWN = 0 # colors.default + ERROR = 1 # colors.emergency + VALID = 2 # colors.highlight + CANT_CONNECT = 3 # colors.warning + CONNECTED = 4 # colors.success + + +class DeviceValidationListItem(QtWidgets.QWidget): + """Custom list item widget showing device name and validation status.""" + + status_changed = QtCore.Signal(int) # Signal emitted when status changes -> ValidationStatus + # Signal emitted when device was validated with name, success, msg + device_validated = QtCore.Signal(str, str) + + def __init__( + self, + device_config: dict[str, dict], + status: ValidationStatus, + status_icons: dict[ValidationStatus, QtGui.QPixmap], + validate_icon: QtGui.QPixmap, + parent=None, + static_device_test=None, + ): + super().__init__(parent) + if len(device_config.keys()) > 1: + logger.warning( + f"Multiple devices found for config: {list(device_config.keys())}, using first one" + ) + self._static_device_test = static_device_test + self.device_name = list(device_config.keys())[0] + self.device_config = device_config + self.status: ValidationStatus = status + colors = get_accent_colors() + self._status_icon = status_icons + self._validate_icon = validate_icon + self._setup_ui() + self._update_status_indicator() + + def _setup_ui(self): + """Setup the UI for the list item.""" + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + # Device name label + self.name_label = QtWidgets.QLabel(self.device_name) + self.name_label.setStyleSheet("font-weight: bold;") + layout.addWidget(self.name_label) + + # Make sure status is on the right + layout.addStretch() + self.request_validation_button = QtWidgets.QPushButton("Validate") + self.request_validation_button.setIcon(self._validate_icon) + if self._static_device_test is None: + self.request_validation_button.setDisabled(True) + else: + self.request_validation_button.clicked.connect(self.on_request_validation) + # self.request_validation_button.setVisible(False) -> Hide it?? + layout.addWidget(self.request_validation_button) + # Status indicator + self.status_indicator = QtWidgets.QLabel() + self._update_status_indicator() + layout.addWidget(self.status_indicator) + + @SafeSlot() + def on_request_validation(self): + """Handle validate button click.""" + if self._static_device_test is None: + logger.warning("Static device test not available.") + return + self._static_device_test.config = self.device_config + # TODO logic if connect is allowed + ret = self._static_device_test.run_with_list_output(connect=False)[0] + if ret.success: + self.set_status(ValidationStatus.VALID) + else: + self.set_status(ValidationStatus.ERROR) + self.device_validated.emit(ret.name, ret.message) + + def _update_status_indicator(self): + """Update the status indicator color based on validation status.""" + self.status_indicator.setPixmap(self._status_icon[self.status]) + + def set_status(self, status: ValidationStatus): + """Update the validation status.""" + self.status = status + self._update_status_indicator() + self.status_changed.emit(self.status) + + def get_status(self) -> ValidationStatus: + """Get the current validation status.""" + return self.status + + +class DeviceManagerOphydTest(BECWidget, QtWidgets.QWidget): + + config_changed = QtCore.Signal( + dict, dict + ) # Signal emitted when the device config changed, new_config, old_config + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent, client=client) + if not READY_TO_TEST: + self._set_disabled() + static_device_test = None + else: + from ophyd_devices.utils.static_device_test import StaticDeviceTest + + static_device_test = StaticDeviceTest(config_dict={}) + self._static_device_test = static_device_test + self._device_config: dict[str, dict] = {} + self._main_layout = QtWidgets.QVBoxLayout(self) + self._main_layout.setContentsMargins(0, 0, 0, 0) + self._main_layout.setSpacing(4) + + # Setup icons + colors = get_accent_colors() + self._validate_icon = material_icon( + icon_name="play_arrow", color=colors.default, filled=True + ) + self._status_icons = { + ValidationStatus.UNKNOWN: material_icon( + icon_name="circle", size=(12, 12), color=colors.default, filled=True + ), + ValidationStatus.ERROR: material_icon( + icon_name="circle", size=(12, 12), color=colors.emergency, filled=True + ), + ValidationStatus.VALID: material_icon( + icon_name="circle", size=(12, 12), color=colors.highlight, filled=True + ), + ValidationStatus.CANT_CONNECT: material_icon( + icon_name="circle", size=(12, 12), color=colors.warning, filled=True + ), + ValidationStatus.CONNECTED: material_icon( + icon_name="circle", size=(12, 12), color=colors.success, filled=True + ), + } + + self.setLayout(self._main_layout) + + # splitter + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) + self._main_layout.addWidget(self.splitter) + + # Add custom list + self.setup_device_validation_list() + + # Setup text box + self.setup_text_box() + + # Connect signals + self.config_changed.connect(self.on_config_updated) + + @SafeSlot(list) + def on_device_config_update(self, config: list[dict]): + old_cfg = self._device_config + self._device_config = self._compile_device_config_list(config) + self.config_changed.emit(self._device_config, old_cfg) + + def _compile_device_config_list(self, config: list[dict]) -> dict[str, dict]: + return {dev["name"]: {k: v for k, v in dev.items() if k != "name"} for dev in config} + + @SafeSlot(dict, dict) + def on_config_updated(self, new_config: dict, old_config: dict): + """Handle config updates and refresh the validation list.""" + # Find differences for potential re-validation + diffs = self._find_diffs(new_config, old_config) + # Check diff first + for diff in diffs: + if not diff: + continue + if len(diff) > 1: + logger.warning(f"Multiple devices found in diff: {diff}, using first one") + name = list(diff.keys())[0] + if name in self.client.device_manager.devices: + status = ValidationStatus.CONNECTED + else: + status = ValidationStatus.UNKNOWN + if self.get_device_status(diff) is None: + self.add_device(diff, status) + else: + self.update_device_status(diff, status) + + def _find_diffs(self, new_config: dict, old_config: dict) -> list[dict]: + """ + Return list of keys/paths where d1 and d2 differ. This goes recursively through the dictionary. + + Args: + new_config: The first dictionary to compare. + old_config: The second dictionary to compare. + """ + diffs = [] + keys = set(new_config.keys()) | set(old_config.keys()) + for k in keys: + if k not in old_config: # New device + diffs.append({k: new_config[k]}) + continue + if k not in new_config: # Removed device + diffs.append({k: old_config[k]}) + continue + # Compare device config if exists in both + v1, v2 = old_config[k], new_config[k] + if isinstance(v1, dict) and isinstance(v2, dict): + if self._find_diffs(v2, v1): # recurse: something inside changed + diffs.append({k: new_config[k]}) + elif v1 != v2: + diffs.append({k: new_config[k]}) + return diffs + + def setup_device_validation_list(self): + """Setup the device validation list.""" + # Create the custom validation list widget + self.validation_list = QtWidgets.QListWidget() + self.validation_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.splitter.addWidget(self.validation_list) + # self._main_layout.addWidget(self.validation_list) + + def setup_text_box(self): + """Setup the text box for device validation messages.""" + self.validation_text_box = QtWidgets.QTextEdit() + self.validation_text_box.setReadOnly(True) + self.splitter.addWidget(self.validation_text_box) + # self._main_layout.addWidget(self.validation_text_box) + + @SafeSlot(str, str) + def on_device_validated(self, device_name: str, message: str): + """Handle device validation results.""" + text = f"Device {device_name} was validated. Message: {message}" + self.validation_text_box.setText(text) + + def _set_disabled(self) -> None: + """Disable the full view""" + self.setDisabled(True) + + def add_device( + self, device_config: dict[str, dict], status: ValidationStatus = ValidationStatus.UNKNOWN + ): + """Add a device to the validation list.""" + # Create the custom widget + item_widget = DeviceValidationListItem( + device_config=device_config, + status=status, + status_icons=self._status_icons, + validate_icon=self._validate_icon, + static_device_test=self._static_device_test, + ) + + # Create a list widget item + list_item = QtWidgets.QListWidgetItem() + list_item.setSizeHint(item_widget.sizeHint()) + + # Add item to list and set custom widget + self.validation_list.addItem(list_item) + self.validation_list.setItemWidget(list_item, item_widget) + item_widget.device_validated.connect(self.on_device_validated) + + def update_device_status(self, device_config: dict[str, dict], status: ValidationStatus): + """Update the validation status for a specific device.""" + for i in range(self.validation_list.count()): + item = self.validation_list.item(i) + widget = self.validation_list.itemWidget(item) + if ( + isinstance(widget, DeviceValidationListItem) + and widget.device_config == device_config + ): + widget.set_status(status) + break + + def clear_devices(self): + """Clear all devices from the list.""" + self.validation_list.clear() + + def get_device_status(self, device_config: dict[str, dict]) -> ValidationStatus | None: + """Get the validation status for a specific device.""" + for i in range(self.validation_list.count()): + item = self.validation_list.item(i) + widget = self.validation_list.itemWidget(item) + if ( + isinstance(widget, DeviceValidationListItem) + and widget.device_config == device_config + ): + return widget.get_status() + return None + + +if __name__ == "__main__": + import sys + + # pylint: disable=ungrouped-imports + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + device_manager_ophyd_test = DeviceManagerOphydTest() + cfg = device_manager_ophyd_test.client.device_manager._get_redis_device_config() + cfg.append({"name": "Wrong_Device", "type": "test"}) + device_manager_ophyd_test.on_device_config_update(cfg) + device_manager_ophyd_test.show() + device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test") + device_manager_ophyd_test.resize(800, 600) + sys.exit(app.exec_()) From 7935b9b8204871dc99f11ee5ed701cfafd81918e Mon Sep 17 00:00:00 2001 From: appel_c Date: Wed, 27 Aug 2025 08:20:50 +0200 Subject: [PATCH 045/161] refactor: refactor device_manager_view --- bec_widgets/examples/bec_main_app/__init__.py | 0 .../examples/bec_main_app/bec_main_app.py | 67 ++ .../device_manager_view.py | 380 ++++++++++-- .../device_manager_widget.py | 39 +- .../advanced_dock_area/states/user/test.ini | 234 +++++++ .../device_manager/components/__init__.py | 4 + .../components/device_table_view.py | 475 ++++++++++----- .../components/dm_config_view.py | 26 +- .../components/dm_docstring_view.py | 128 ++++ .../components/dm_ophyd_test.py | 573 ++++++++++-------- 10 files changed, 1457 insertions(+), 469 deletions(-) create mode 100644 bec_widgets/examples/bec_main_app/__init__.py create mode 100644 bec_widgets/examples/bec_main_app/bec_main_app.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini create mode 100644 bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py diff --git a/bec_widgets/examples/bec_main_app/__init__.py b/bec_widgets/examples/bec_main_app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/examples/bec_main_app/bec_main_app.py b/bec_widgets/examples/bec_main_app/bec_main_app.py new file mode 100644 index 000000000..4c70c1981 --- /dev/null +++ b/bec_widgets/examples/bec_main_app/bec_main_app.py @@ -0,0 +1,67 @@ +from qtpy import QtCore, QtWidgets + +from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea + + +class BECMainApp(QtWidgets.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # Main layout + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Tab widget as central area + self.tabs = QtWidgets.QTabWidget(self) + self.tabs.setContentsMargins(0, 0, 0, 0) + self.tabs.setTabPosition(QtWidgets.QTabWidget.West) # Tabs on the left side + + layout.addWidget(self.tabs) + # Add DM + self._add_device_manager_view() + + # Add Plot area + self._add_ad_dockarea() + + # Adjust size of tab bar + # TODO not yet properly working, tabs a spread across the full length, to be checked! + tab_bar = self.tabs.tabBar() + tab_bar.setFixedWidth(tab_bar.sizeHint().width()) + + def _add_device_manager_view(self) -> None: + self.device_manager_view = DeviceManagerView(parent=self) + self.add_tab(self.device_manager_view, "Device Manager") + + def _add_ad_dockarea(self) -> None: + self.advanced_dock_area = AdvancedDockArea(parent=self) + self.add_tab(self.advanced_dock_area, "Plot Area") + + def add_tab(self, widget: QtWidgets.QWidget, title: str): + """Add a custom QWidget as a tab.""" + tab_container = QtWidgets.QWidget() + tab_layout = QtWidgets.QVBoxLayout(tab_container) + tab_layout.setContentsMargins(0, 0, 0, 0) + tab_layout.setSpacing(0) + + tab_layout.addWidget(widget) + self.tabs.addTab(tab_container, title) + + +if __name__ == "__main__": + import sys + + from bec_lib.bec_yaml_loader import yaml_load + from bec_qthemes import apply_theme + + app = QtWidgets.QApplication(sys.argv) + apply_theme("light") + win = BECMainApp() + config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" + cfg = yaml_load(config_path) + cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) + win.device_manager_view.device_table_view.set_device_config(cfg) + win.resize(1920, 1080) + win.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index d3950dca8..62ab960a2 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -1,32 +1,36 @@ -from typing import List +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, List import PySide6QtAds as QtAds import yaml -from bec_qthemes import material_icon +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.file_utils import DeviceConfigWriter +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path +from bec_qthemes import apply_theme from PySide6QtAds import CDockManager, CDockWidget from qtpy.QtCore import Qt, QTimer -from qtpy.QtWidgets import ( - QPushButton, - QSizePolicy, - QSplitter, - QStackedLayout, - QTreeWidget, - QVBoxLayout, - QWidget, -) +from qtpy.QtWidgets import QFileDialog, QMessageBox, QSplitter, QVBoxLayout, QWidget from bec_widgets import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea -from bec_widgets.widgets.control.device_manager.components.device_table_view import DeviceTableView -from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView -from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ( - DeviceManagerOphydTest, +from bec_widgets.widgets.control.device_manager.components import ( + DeviceTableView, + DMConfigView, + DMOphydTest, + DocstringView, ) -from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget -from bec_widgets.widgets.editors.web_console.web_console import WebConsole -from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox -from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + +if TYPE_CHECKING: + from bec_lib.client import BECClient + +logger = bec_logger.logger def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: @@ -66,7 +70,7 @@ def apply(): class DeviceManagerView(BECWidget, QWidget): def __init__(self, parent=None, *args, **kwargs): - super().__init__(parent, *args, **kwargs) + super().__init__(parent=parent, client=None, *args, **kwargs) # Top-level layout hosting a toolbar and the dock manager self._root_layout = QVBoxLayout(self) @@ -75,38 +79,52 @@ def __init__(self, parent=None, *args, **kwargs): self.dock_manager = CDockManager(self) self._root_layout.addWidget(self.dock_manager) - # Initialize the widgets - self.explorer = IDEExplorer(self) # TODO will be replaced by explorer widget + # Available Resources Widget + self.available_devices = QWidget(self) + self.available_devices_dock = QtAds.CDockWidget("Available Devices", self) + self.available_devices_dock.setWidget(self.available_devices) + + # Device Table View widget self.device_table_view = DeviceTableView(self) - # Placeholder - self.dm_config_view = DMConfigView(self) + self.device_table_view_dock = QtAds.CDockWidget("Device Table", self) + self.device_table_view_dock.setWidget(self.device_table_view) - # Placeholder for ophyd test - WebConsole.startup_cmd = "ipython" - self.ophyd_test = DeviceManagerOphydTest(self) - self.ophyd_test_dock = QtAds.CDockWidget("Ophyd Test", self) - self.ophyd_test_dock.setWidget(self.ophyd_test) + # Device Config View widget + self.dm_config_view = DMConfigView(self) + self.dm_config_view_dock = QtAds.CDockWidget("Device Config View", self) + self.dm_config_view_dock.setWidget(self.dm_config_view) - # Create the dock widgets - self.explorer_dock = QtAds.CDockWidget("Explorer", self) - self.explorer_dock.setWidget(self.explorer) + # Docstring View + self.dm_docs_view = DocstringView(self) + self.dm_docs_view_dock = QtAds.CDockWidget("Docstring View", self) + self.dm_docs_view_dock.setWidget(self.dm_docs_view) - self.device_table_view_dock = QtAds.CDockWidget("Device Table", self) - self.device_table_view_dock.setWidget(self.device_table_view) + # Ophyd Test view + self.ophyd_test_view = DMOphydTest(self) + self.ophyd_test_dock_view = QtAds.CDockWidget("Ophyd Test View", self) + self.ophyd_test_dock_view.setWidget(self.ophyd_test_view) - # Device Table will be central widget - self.dock_manager.setCentralWidget(self.device_table_view_dock) + # Arrange widgets within the QtAds dock manager - self.dm_config_view_dock = QtAds.CDockWidget("YAML Editor", self) - self.dm_config_view_dock.setWidget(self.dm_config_view) + # Central widget area + self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock) + self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, + self.dm_docs_view_dock, + self.central_dock_area, + ) - # Add the dock widgets to the dock manager - self.dock_manager.addDockWidget(QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock) - monaco_yaml_area = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.RightDockWidgetArea, self.dm_config_view_dock + # Left Area + self.left_dock_area = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock + ) + self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_config_view_dock, self.left_dock_area ) + + # Right area self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.BottomDockWidgetArea, self.ophyd_test_dock, monaco_yaml_area + QtAds.DockWidgetArea.RightDockWidgetArea, self.ophyd_test_dock_view ) for dock in self.dock_manager.dockWidgets(): @@ -122,10 +140,252 @@ def __init__(self, parent=None, *args, **kwargs): area.titleBar().setVisible(False) # Apply stretch after the layout is done - self.set_default_view([2, 5, 3], [5, 5]) + self.set_default_view([2, 8, 2], [3, 1]) + # self.set_default_view([2, 8, 2], [2, 2, 4]) # Connect slots self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config) + self.device_table_view.selected_device.connect(self.dm_docs_view.on_select_config) + self.ophyd_test_view.device_validated.connect( + self.device_table_view.update_device_validation + ) + self.device_table_view.device_configs_added.connect(self.ophyd_test_view.add_device_configs) + + self._add_toolbar() + + def _add_toolbar(self): + self.toolbar = ModularToolBar(self) + + # Add IO actions + self._add_io_actions() + self._add_table_actions() + self.toolbar.show_bundles(["IO", "Table"]) + self._root_layout.insertWidget(0, self.toolbar) + + def _add_io_actions(self): + # Create IO bundle + io_bundle = ToolbarBundle("IO", self.toolbar.components) + + # Add load config from plugin dir + self.toolbar.add_bundle(io_bundle) + + load = MaterialIconAction( + icon_name="file_open", parent=self, tooltip="Load configuration file from disk" + ) + self.toolbar.components.add_safe("load", load) + load.action.triggered.connect(self._load_file_action) + io_bundle.add_action("load") + + # Add safe to disk + safe_to_disk = MaterialIconAction( + icon_name="file_save", parent=self, tooltip="Save config to disk" + ) + self.toolbar.components.add_safe("safe_to_disk", safe_to_disk) + safe_to_disk.action.triggered.connect(self._safe_to_disk_action) + io_bundle.add_action("safe_to_disk") + + # Add load config from redis + load_redis = MaterialIconAction( + icon_name="cached", parent=self, tooltip="Load current config from Redis" + ) + load_redis.action.triggered.connect(self._load_redis_action) + self.toolbar.components.add_safe("load_redis", load_redis) + io_bundle.add_action("load_redis") + + # Update config action + update_config_redis = MaterialIconAction( + icon_name="cloud_upload", parent=self, tooltip="Update current config in Redis" + ) + update_config_redis.action.triggered.connect(self._update_redis_action) + self.toolbar.components.add_safe("update_config_redis", update_config_redis) + io_bundle.add_action("update_config_redis") + + # Table actions + + def _add_table_actions(self) -> None: + table_bundle = ToolbarBundle("Table", self.toolbar.components) + + # Add load config from plugin dir + self.toolbar.add_bundle(table_bundle) + + # Reset composed view + reset_composed = MaterialIconAction( + icon_name="delete_sweep", parent=self, tooltip="Reset current composed config view" + ) + reset_composed.action.triggered.connect(self._reset_composed_view) + self.toolbar.components.add_safe("reset_composed", reset_composed) + table_bundle.add_action("reset_composed") + + # Add device + add_device = MaterialIconAction(icon_name="add", parent=self, tooltip="Add new device") + add_device.action.triggered.connect(self._add_device_action) + self.toolbar.components.add_safe("add_device", add_device) + table_bundle.add_action("add_device") + + # Remove device + remove_device = MaterialIconAction(icon_name="remove", parent=self, tooltip="Remove device") + remove_device.action.triggered.connect(self._remove_device_action) + self.toolbar.components.add_safe("remove_device", remove_device) + table_bundle.add_action("remove_device") + + # Rerun validation + rerun_validation = MaterialIconAction( + icon_name="checklist", parent=self, tooltip="Run device validation on selected devices" + ) + rerun_validation.action.triggered.connect(self._rerun_validation_action) + self.toolbar.components.add_safe("rerun_validation", rerun_validation) + table_bundle.add_action("rerun_validation") + + # Most likly, no actions on available devices + # Actions (vielleicht bundle fuer available devices ) + # - reset composed view + # - add new device (EpicsMotor, EpicsMotorECMC, EpicsSignal, CustomDevice) + # - remove device + # - rerun validation (with/without connect) + + # IO actions + + @SafeSlot() + def _load_file_action(self): + """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" + # Check if plugin repo is installed... + try: + plugin_path = plugin_repo_path() + plugin_name = plugin_package_name() + config_path = os.path.join(plugin_path, plugin_name, "device_configs") + except ValueError: + # Get the recovery config path as fallback + config_path = self._get_recovery_config_path() + logger.warning( + f"No plugin repository installed, fallback to recovery config path: {config_path}" + ) + + # Implement the file loading logic here + start_dir = os.path.abspath(config_path) + file_path, _ = QFileDialog.getOpenFileName( + self, caption="Select Config File", dir=start_dir + ) + if file_path: + try: + config = yaml_load(file_path) + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + self.device_table_view.set_device_config( + config + ) # TODO ADD QDialog with 'replace', 'add' & 'cancel' + + # TODO would we ever like to add the current config to an existing composition + @SafeSlot() + def _load_redis_action(self): + """Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar.""" + reply = QMessageBox.question( + self, + "Load currently active config", + "Do you really want to flush the current config and reload?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.Yes: + cfg = {} + config_list = self.client.device_manager._get_redis_device_config() + for item in config_list: + k = item["name"] + item.pop("name") + cfg[k] = item + self.device_table_view.set_device_config(cfg) + else: + return + + @SafeSlot() + def _safe_to_disk_action(self): + """Action for the 'safe_to_disk' action to save the current config to disk.""" + # Check if plugin repo is installed... + try: + config_path = self._get_recovery_config_path() + except ValueError: + # Get the recovery config path as fallback + config_path = os.path.abspath(os.path.expanduser("~")) + logger.warning(f"Failed to find recovery config path, fallback to: {config_path}") + + # Implement the file loading logic here + file_path, _ = QFileDialog.getSaveFileName( + self, caption="Save Config File", dir=config_path + ) + if file_path: + config = self.device_table_view.get_device_config() + with open(file_path, "w") as file: + file.write(yaml.dump(config)) + + # TODO add here logic, should be asyncronous, but probably block UI, and show a loading spinner. If failed, it should report.. + @SafeSlot() + def _update_redis_action(self): + """Action for the 'update_redis' action to update the current config in Redis.""" + config = self.device_table_view.get_device_config() + reply = QMessageBox.question( + self, + "Not implemented yet", + "This feature has not been implemented yet, will be coming soon...!!", + QMessageBox.Cancel, + QMessageBox.Cancel, + ) + + # Table actions + + @SafeSlot() + def _reset_composed_view(self): + """Action for the 'reset_composed_view' action to reset the composed view.""" + reply = QMessageBox.question( + self, + "Clear View", + "You are about to clear the current composed config view, please confirm...", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.Yes: + self.device_table_view.clear_device_configs() + + # TODO Here we would like to implement a custom popup view, that allows to add new devices + # We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device + # For all default Epics devices, we would like to preselect relevant fields, and prompt them with the proper deviceConfig args already, i.e. 'prefix', 'read_pv', 'write_pv' etc.. + # For custom Device, they should receive all options. It might be cool to get a side panel with docstring view of the class upon inspecting it to make it easier in case deviceConfig entries are required.. + @SafeSlot() + def _add_device_action(self): + """Action for the 'add_device' action to add a new device.""" + # Implement the logic to add a new device + reply = QMessageBox.question( + self, + "Not implemented yet", + "This feature has not been implemented yet, will be coming soon...!!", + QMessageBox.Cancel, + QMessageBox.Cancel, + ) + + # TODO fix the device table remove actions. This is currently not working properly... + @SafeSlot() + def _remove_device_action(self): + """Action for the 'remove_device' action to remove a device.""" + reply = QMessageBox.question( + self, + "Not implemented yet", + "This feature has not been implemented yet, will be coming soon...!!", + QMessageBox.Cancel, + QMessageBox.Cancel, + ) + + # TODO implement proper logic for validation. We should also carefully review how these jobs update the table, and how we can cancel pending validations + # in case they are no longer relevant. We might want to 'block' the interactivity on the items for which validation runs with 'connect'! + @SafeSlot() + def _rerun_validation_action(self): + """Action for the 'rerun_validation' action to rerun validation on selected devices.""" + # Implement the logic to rerun validation on selected devices + reply = QMessageBox.question( + self, + "Not implemented yet", + "This feature has not been implemented yet, will be coming soon...!!", + QMessageBox.Cancel, + QMessageBox.Cancel, + ) ####### Default view has to be done with setting up splitters ######## def set_default_view(self, horizontal_weights: list, vertical_weights: list): @@ -189,18 +449,40 @@ def _coerce_v(x): v = [1, 1] self.set_default_view(h, v) + def _get_recovery_config_path(self) -> str: + """Get the recovery config path from the log_writer config.""" + # pylint: disable=protected-access + log_writer_config: BECClient = self.client._service_config.config.get("log_writer", {}) + writer = DeviceConfigWriter(service_config=log_writer_config) + return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) + if __name__ == "__main__": import sys + from copy import deepcopy + from bec_lib.bec_yaml_loader import yaml_load from qtpy.QtWidgets import QApplication + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + app = QApplication(sys.argv) + w = QWidget() + l = QVBoxLayout() + w.setLayout(l) + apply_theme("dark") + button = DarkModeButton() + l.addWidget(button) device_manager_view = DeviceManagerView() - config = device_manager_view.client.device_manager._get_redis_device_config() - device_manager_view.device_table_view.set_device_config(config) - device_manager_view.show() - device_manager_view.setWindowTitle("Device Manager View") - device_manager_view.resize(1200, 800) + l.addWidget(device_manager_view) + # config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" + # cfg = yaml_load(config_path) + # cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) + + # # config = device_manager_view.client.device_manager._get_redis_device_config() + # device_manager_view.device_table_view.set_device_config(cfg) + w.show() + w.setWindowTitle("Device Manager View") + w.resize(1920, 1080) # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime sys.exit(app.exec_()) diff --git a/bec_widgets/examples/device_manager_view/device_manager_widget.py b/bec_widgets/examples/device_manager_view/device_manager_widget.py index 98e34fefb..9d4c9c80b 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_widget.py +++ b/bec_widgets/examples/device_manager_view/device_manager_widget.py @@ -2,6 +2,10 @@ from __future__ import annotations +import os + +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtWidgets @@ -9,6 +13,8 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +logger = bec_logger.logger + class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): @@ -41,19 +47,50 @@ def _customize_overlay(self): self._overlay_widget.setSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding ) + # Load current config self.button_load_current_config = QtWidgets.QPushButton("Load Current Config") icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False) self.button_load_current_config.setIcon(icon) self._overlay_layout.addWidget(self.button_load_current_config) self.button_load_current_config.clicked.connect(self._load_config_clicked) + # Load config from disk + self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File") + icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False) + self.button_load_config_from_file.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_config_from_file) + self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked) self._overlay_widget.setVisible(True) + def _load_config_from_file_clicked(self): + """Handle click on 'Load Config From File' button.""" + start_dir = os.path.expanduser("~") + file_path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, caption="Select Config File", dir=start_dir + ) + if file_path: + self._load_config_from_file(file_path) + + def _load_config_from_file(self, file_path: str): + try: + config = yaml_load(file_path) + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + config_list = [] + for name, cfg in config.items(): + config_list.append(cfg) + config_list[-1]["name"] = name + self.device_manager_view.device_table_view.set_device_config(config_list) + # self.device_manager_view.ophyd_test.on_device_config_update(config) + self.stacked_layout.setCurrentWidget(self.device_manager_view) + @SafeSlot() def _load_config_clicked(self): """Handle click on 'Load Current Config' button.""" config = self.client.device_manager._get_redis_device_config() + config.append({"name": "wrong_device", "some_value": 1}) self.device_manager_view.device_table_view.set_device_config(config) - self.device_manager_view.ophyd_test.on_device_config_update(config) + # self.device_manager_view.ophyd_test.on_device_config_update(config) self.stacked_layout.setCurrentWidget(self.device_manager_view) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini b/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini new file mode 100644 index 000000000..6188162c0 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini @@ -0,0 +1,234 @@ +[BECMainWindowNoRPC.AdvancedDockArea] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +baseSize=@Size(0 0) +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +enabled=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 29 2075 974) +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +lock_workspace=false +maximumSize=@Size(16777215 16777215) +minimumSize=@Size(0 0) +mode=developer +mouseTracking=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +statusTip= +styleSheet= +tabletTracking=false +toolTip= +toolTipDuration=-1 +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle=Advanced Dock Area + +[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.BECQueue.dockWidgetScrollArea.qt_scrollarea_viewport.BECQueue] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +baseSize=@Size(0 0) +compact_view=false +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +enabled=true +expand_popup=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 0 1252 897) +hide_toolbar=false +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +label=BEC Queue +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +maximumSize=@Size(16777215 16777215) +minimumSize=@Size(0 0) +mouseTracking=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +statusTip= +styleSheet= +tabletTracking=false +toolTip= +toolTipDuration=-1 +tooltip=BEC Queue status +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle= + +[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.Waveform.dockWidgetScrollArea.qt_scrollarea_viewport.Waveform] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +auto_range_x=true +auto_range_y=true +baseSize=@Size(0 0) +color_palette=plasma +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +curve_json=[] +enable_fps_monitor=false +enable_popups=true +enable_side_panel=false +enable_toolbar=true +enabled=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 0 798 897) +inner_axes=true +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +legend_label_size=9 +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +lock_aspect_ratio=false +max_dataset_size_mb=10 +maximumSize=@Size(16777215 16777215) +minimal_crosshair_precision=3 +minimumSize=@Size(0 0) +mouseTracking=false +outer_axes=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +skip_large_dataset_check=false +skip_large_dataset_warning=false +statusTip= +styleSheet= +tabletTracking=false +title= +toolTip= +toolTipDuration=-1 +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle= +x_entry= +x_grid=false +x_label= +x_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) +x_log=false +x_mode=auto +y_grid=false +y_label= +y_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) +y_log=false + +[BECMainWindowNoRPC.AdvancedDockArea.ModularToolBar.QWidget.DarkModeButton] +acceptDrops=false +accessibleDescription= +accessibleIdentifier= +accessibleName= +autoFillBackground=false +baseSize=@Size(0 0) +contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) +cursor=@Variant(\0\0\0J\0\0) +dark_mode_enabled=false +enabled=true +focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +geometry=@Rect(0 0 40 40) +inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) +layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) +locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) +maximumSize=@Size(40 40) +minimumSize=@Size(40 40) +mouseTracking=false +palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) +sizeIncrement=@Size(0 0) +sizePolicy=@Variant(\0\0\0K\0\0\0U) +statusTip= +styleSheet= +tabletTracking=false +toolTip= +toolTipDuration=-1 +updatesEnabled=true +visible=true +whatsThis= +windowFilePath= +windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" +windowIconText= +windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) +windowModified=false +windowOpacity=1 +windowTitle= + +[BECMainWindowNoRPC.AdvancedDockArea.dockSettingsAction] +autoRepeat=true +checkable=false +checked=false +enabled=true +font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) +icon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\0\tpHYs\0\0\v\x13\0\0\v\x13\x1\0\x9a\x9c\x18\0\0\x4\xc9IDATX\x85\xed\x99]lSu\x18\xc6\x7f\xef\xe9\x6\x85\xb0\xa0\xc1\x8b\xe9\x2\x46\xe3`\xa0!\xa0\x88\x6\x35*\x9a\xb9\x96\xb6\v\xed*\x82\x80\x46\x12\xe3\x95\x1a\x14I\xc4\x8b\xde(~$&&\x10/d\x17\x80\xdf]7i\xb7u \xc1%\x9a\x18\xd4\xa0\xf1\x3\x18j\"D\x3\xc6\xc4\x44\x18\xee\xa3=\xff\xd7\v$t]{\xce\x61\xad\xf3\x66\xcf\xddy\xdf\xe7<\xefs\xcey\xfb?\xef\xf9\x17\xa6\x30\x85\x31\x90j\x88\xb4\x84\xa2\xab\x5iu\xe2(\xba\xaf\xaf\xbb\xb3\xab\xd2Z5\x95\n\0 \xb2\x1a\xd8\xe0\x42\xb2\x80\x8a\r[\x95\n\0\b,\xf4\xc0i\xaa\x46\xadj\x18\x16\xbc\x99i\xa2\n-X\xb1\xe1P(~\r0\xcb\x3\xb5.\x10\x88\x36TZ\xcf\xd1p(\x14o\b\x6\xe3\xf5N\x9c\x9c\xcf\xb8\xb6\xc3\x45H\xad:r\x83\xc1x}(\x14w\xbc(G\xc3y1;\xd5gN\x6\xc3\xb1]\xcd\xe1\xd8\xb8\xc7\x1e\x88\xc4\x96Z\x86-\xde\xec\x82\xaal[\xd5\xda\xb6\xa2\x38\xde\x12\x89/h\tG\xdfR\x9f\x39i\x8b\xd9\xe1\xa4Q\xb6\xa7VE\xa2\xf7\x1a\x95\x43\x45\xe1\x8c\x31\xf2Z~\x9a}tZNv \xf2\x90W\xb3\x63\xabJ\x97\xed\xb3\x9f\xf0\xd9\xd2\xa8*[\x4\"\x85^,\xd1\x95=\xe9\xceO<\x1b\x8e\xc7\xe3\xbe\xc1\x11\xf3\x15\xca\x92\x32%\x87\x1\xff\x84\xccz\xd1\x10\xbe\x99\x35\xddZ\x96L&\xed\xe2T\xc9\x96\x18\x1c\x31\x8f\x38\x98\xa5l\xa1\xcb\x43y\re\xc9\xf9!\xddX*5\xee\xeG\"\x91\xba\x9c\xd6\x9e\0\x1c\x7fl\x93\x80\x33\x43~\xab\xb1?\x99\x1c,\f\x8e\xbb\xc3\x39\xad\xdd\xca\xffo\x16\xa0~\xe6\x88n-\xe\x8e\xb9\xc3\xe1p|^\x1e\x33@u\x1ey50\\\x83\xb5 \x93I\x9e\xba\x18\x18s\x87m\xb1\x1f\xa6\x12\xb3\xa2{\xc0\xba\xa7Vr\xf5\x62[W\x83\xb9\xf\xf8`\xc2z\xe0\xcf\x61\xd6\x15\x6\xc6\f?\xcbo^\xfc\xca\xe1#\xdf\x8e\xa2\xb2\x1d\xa8\xbd\f\xe1?Ty\xb4/\xd3\xd9[\x14?\x3\x1cj\x89\xb4}(\xaa\xed\xc0\x15\x97\xa1\x39\x82\xf0l_:\xb5\xb3\x30XrYk\tGo\x15\xe4}\xe0z/\xcajh\xe9\xebI\xedw\xe2\x4\xc3\xb1\x98\x42\x87G\xb3\xdfY\xc6\xac\xeb\xe9\xe9\xfa\xbe\x38QrY\xeb\xcbt~I\xde\xbf\x14x\xd7MY\xa0\xdd\xcd,@o&\x95\xc2K{(o\f\x9d\xab[^\xca,8\xcc\xc3\xd9\xec;g\x81\xf5\x81p\xb4\x1\xe4\xeer<\xa3V\xbb\xab\x89\x7f!b\xdaU\xad\x35\x65\xf3\xca\xc1\xde\xee\xd4\xd3N\x1an\xd3\x9a\"\xe2\x38\x89\xf9kr\xc7\\4.\x89\xe5\xc4\x91\xab\x16W\xbaiTe\x80\x9fL\xb8\x19\x16T\a\x9d\b\xc3\xf9\xda\xaa\x8d\x97\xa8\xfc\xe5\xa6Q\xb6\x87\xef\x8f\xc7gO\x1b\x36o*\x94\xed_\0K\xcc&\xe0s\xb7\x42\0\xaa\xd6&\x17\xc6\xca`8\xb6+?2\xf3\xa9\x3\a\xf6\x9e/\xc5(\xb9\xac\x5[c\xb7\x61xO\xe1:OF\xaa\xbd\xac\t'\x8c\xea\xda\xfd\x99\xce#\xc5)_\xe1\x41\"\x91\xb0\x66\xd4]\xf5\x1c\xca\xdb\xc0\x1cO\xe2\x80\b\xf\xdc\x30\x7f\xd1\xf?\x9d\x38\xf6\x63\xa9|K\xa4-\n\xb4\xe3\xfd-:G\x90\xc7\x1a\x9b\x16\xfe\xbd~\xed\x9a\xc3\xfd\xfd\xfdz\xe9Z\n\x10\b\xb7=\xf\xfa\xa2W\xa3\xc5P\xd8+X\xed\x62\x33`\xfbm\x91Qk\x11\xa2\x8f\v<8QM\x90m\xd9L\xc7K\x17\x8f\xc6\xf4\xb0m\xe5\xf6\xfaL\xcd\v\xc0\x8c\tI\xc3\x6\x30\x1b\xd4\aVN@\xd4\xfd$g\f\x91\x37{\n\x3\x63Z\xe2\xe7\x81\x81\xb3\x8d\v\x16NwzQL2\xb6g{:\xd3\x85\x81q\xcb\xda\x90\xdf\xf7*pz\xd2,\x95\xc7\xe9!\xbf\xf5Zq\xd0W\x1c\xf8\xe5\xe8\xd1\xd1\xf9\xf3o\xfc\x13\xc1q\xaf\xec\xbf\x86\xa2O\x1e\xecJ}Q\x1c/\xf9\xe2X\xbe\xec\xa6\xdd\xc0\xd7\xez\xc3U\xf0\xe4\xa4q\xe4\xf6[\x16\xef)\x95(i8\x91H\x18U\xd9\\\x14V\xe0#\xcb\x92;\xec\x1as-\x90\x9a\x98OP\xd8G\xde\x9a\x87\xe8\x9d\n\xfbJ\xd8\xda\x9cH$L\xa9s\x1d\xf7\xba\x2\xe1X\x17\x10\0v\xabX\xaf\xf7\xa5\x93\x3\x85\xf9U\xadm+T\xf5\x65U\xee\xf2\x62T\x84Om[\xb6\xed\xef\xe9\xf8\xec\x82\xef\vh\xe\xc7\x9a,\xf4\x19\x41\x36\"\xd2\x93MwD\xcbj8\x15\b\x85\xe2\r\x96\x35\x92O\xa7\xd3\xbf\x97\xbf\xa8\xb6\x66P\xd7y\xf8\x82\x61\xd3\xdc\x9b\xee\xfa\xb8\\>\x18\x8c\xd7[\x16\xbe\xee\xee\xe4o\x13\x32\xec\x5\xcd\xad\xads}\xa6\xe6\x94;\x13\xc8\xeb\xdcl\xb6\xf3\xd7J\xeaUc\a^\x2\xe1\xd8Y\xdcw0\xcf\x65\x33\xa9\xd9\x14\xb4\xc2\x44P\x8dyX\x81\xe3\x1ex\xc7\xa9\xd0,Ti\x80Wp\xff\xea\x10\xf5\xfc\x65\xe2\x84\xaa\xfc\xc7\x61!)\xd0Q'\x8e\x81\xb4S~\nS\x98 \xfe\x1\x1\xb5\x93\xa4\x97\x89\xb7\xcb\0\0\0\0IEND\xae\x42`\x82)" +iconText=Dock settings +iconVisibleInMenu=false +menuRole=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.MenuRole\x94\x93\x94\x8c\x11TextHeuristicRole\x94\x86\x94R\x94.) +priority=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.Priority\x94\x93\x94\x8c\xeNormalPriority\x94\x86\x94R\x94.) +shortcut= +shortcutContext=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.ShortcutContext\x94\x93\x94\x8c\xeWindowShortcut\x94\x86\x94R\x94.) +shortcutVisibleInContextMenu=false +statusTip= +text=Dock settings +toolTip=Dock settings +visible=true +whatsThis= + +[Perspectives] +1\Name=test +1\State="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" +size=1 + +[mainWindow] +DockingState="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" +Geometry=@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea\0\0\0\0\0\0\0\0\xff\xff\xff\xff\xff\xff\xff\xff\0\0\0\x1\0\0\0\0\xf\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea) +State=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\xf\x80\x4\x95\x4\0\0\0\0\0\0\0\x43\0\x94.) + +[manifest] +widgets\1\closable=true +widgets\1\floatable=true +widgets\1\movable=true +widgets\1\object_name=BECQueue +widgets\1\widget_class=BECQueue +widgets\2\closable=true +widgets\2\floatable=true +widgets\2\movable=true +widgets\2\object_name=PositionerBox +widgets\2\widget_class=PositionerBox +widgets\3\closable=true +widgets\3\floatable=true +widgets\3\movable=true +widgets\3\object_name=Waveform +widgets\3\widget_class=Waveform +widgets\size=3 diff --git a/bec_widgets/widgets/control/device_manager/components/__init__.py b/bec_widgets/widgets/control/device_manager/components/__init__.py index e69de29bb..bec612eef 100644 --- a/bec_widgets/widgets/control/device_manager/components/__init__.py +++ b/bec_widgets/widgets/control/device_manager/components/__init__.py @@ -0,0 +1,4 @@ +from .device_table_view import DeviceTableView +from .dm_config_view import DMConfigView +from .dm_docstring_view import DocstringView +from .dm_ophyd_test import DMOphydTest diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 40f31cec2..423b8f06d 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -3,16 +3,18 @@ from __future__ import annotations import copy -import json +import time from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtGui, QtWidgets from thefuzz import fuzz +from bec_widgets.utils.bec_signal_proxy import BECSignalProxy from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.colors import get_accent_colors, get_theme_palette from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus logger = bec_logger.logger @@ -30,21 +32,25 @@ def helpEvent(self, event, view, option, index): model: DeviceFilterProxyModel = index.model() model_index = model.mapToSource(index) row_dict = model.sourceModel().get_row_data(model_index) - QtWidgets.QToolTip.showText(event.globalPos(), row_dict["description"], view) + description = row_dict.get("description", "") + QtWidgets.QToolTip.showText(event.globalPos(), description, view) return True class CenterCheckBoxDelegate(DictToolTipDelegate): """Custom checkbox delegate to center checkboxes in table cells.""" - def __init__(self, parent=None): + def __init__(self, parent=None, colors=None): super().__init__(parent) - colors = get_accent_colors() + self._colors = colors if colors else get_accent_colors() self._icon_checked = material_icon( - "check_box", size=QtCore.QSize(16, 16), color=colors.default, filled=True + "check_box", size=QtCore.QSize(16, 16), color=self._colors.default, filled=True ) self._icon_unchecked = material_icon( - "check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default, filled=True + "check_box_outline_blank", + size=QtCore.QSize(16, 16), + color=self._colors.default, + filled=True, ) def apply_theme(self, theme: str | None = None): @@ -75,9 +81,51 @@ def editorEvent(self, event, model, option, index): return model.setData(index, new_state, QtCore.Qt.CheckStateRole) +class DeviceValidatedDelegate(DictToolTipDelegate): + """Custom delegate for displaying validated device configurations.""" + + def __init__(self, parent=None, colors=None): + super().__init__(parent) + self._colors = colors if colors else get_accent_colors() + self._icons = { + ValidationStatus.PENDING: material_icon( + icon_name="circle", size=(12, 12), color=self._colors.default, filled=True + ), + ValidationStatus.VALID: material_icon( + icon_name="circle", size=(12, 12), color=self._colors.success, filled=True + ), + ValidationStatus.FAILED: material_icon( + icon_name="circle", size=(12, 12), color=self._colors.emergency, filled=True + ), + } + + def apply_theme(self, theme: str | None = None): + colors = get_accent_colors() + for status, icon in self._icons.items(): + icon.setColor(colors[status]) + + def paint(self, painter, option, index): + status = index.model().data(index, QtCore.Qt.DisplayRole) + if status is None: + return super().paint(painter, option, index) + + pixmap = self._icons.get(status) + if pixmap: + rect = option.rect + pix_rect = pixmap.rect() + pix_rect.moveCenter(rect.center()) + painter.drawPixmap(pix_rect.topLeft(), pixmap) + + super().paint(painter, option, index) + + class WrappingTextDelegate(DictToolTipDelegate): """Custom delegate for wrapping text in table cells.""" + def __init__(self, table: BECTableView, parent=None): + super().__init__(parent) + self._table = table + def paint(self, painter, option, index): text = index.model().data(index, QtCore.Qt.DisplayRole) if not text: @@ -91,12 +139,14 @@ def paint(self, painter, option, index): def sizeHint(self, option, index): text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "") - # if not text: - # return super().sizeHint(option, index) + column_width = self._table.columnWidth(index.column()) - 8 # -4 & 4 - # Use the actual column width - table = index.model().parent() # or store reference to QTableView - column_width = table.columnWidth(index.column()) # - 8 + # Avoid pathological heights for too-narrow columns + min_width = option.fontMetrics.averageCharWidth() * 4 + if column_width < min_width: + fm = QtGui.QFontMetrics(option.font) + elided = fm.elidedText(text, QtCore.Qt.ElideRight, column_width) + return QtCore.QSize(column_width, fm.height() + 4) doc = QtGui.QTextDocument() doc.setDefaultFont(option.font) @@ -104,8 +154,25 @@ def sizeHint(self, option, index): doc.setPlainText(text) layout_height = doc.documentLayout().documentSize().height() - height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off - return QtCore.QSize(column_width, height) + return QtCore.QSize(column_width, int(layout_height) + 4) + + # def sizeHint(self, option, index): + # text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "") + # # if not text: + # # return super().sizeHint(option, index) + + # # Use the actual column width + # table = index.model().parent() # or store reference to QTableView + # column_width = table.columnWidth(index.column()) # - 8 + + # doc = QtGui.QTextDocument() + # doc.setDefaultFont(option.font) + # doc.setTextWidth(column_width) + # doc.setPlainText(text) + + # layout_height = doc.documentLayout().documentSize().height() + # height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off + # return QtCore.QSize(column_width, height) class DeviceTableModel(QtCore.QAbstractTableModel): @@ -115,10 +182,16 @@ class DeviceTableModel(QtCore.QAbstractTableModel): Sort logic is implemented directly on the data of the table view. """ - def __init__(self, device_config: list[dict] | None = None, parent=None): + device_configs_added = QtCore.Signal(dict) # Dict[str, dict] of configs that were added + devices_removed = QtCore.Signal(list) # List of strings with device names that were removed + + def __init__(self, parent=None): super().__init__(parent) - self._device_config = device_config or [] + self._device_config: dict[str, dict] = {} + self._list_items: list[dict] = [] + self._validation_status: dict[str, ValidationStatus] = {} self.headers = [ + "", "name", "deviceClass", "readoutPriority", @@ -133,7 +206,7 @@ def __init__(self, device_config: list[dict] | None = None, parent=None): ############################################### def rowCount(self, parent=QtCore.QModelIndex()) -> int: - return len(self._device_config) + return len(self._list_items) def columnCount(self, parent=QtCore.QModelIndex()) -> int: return len(self.headers) @@ -147,15 +220,20 @@ def get_row_data(self, index: QtCore.QModelIndex) -> dict: """Return the row data for the given index.""" if not index.isValid(): return {} - return copy.deepcopy(self._device_config[index.row()]) + return copy.deepcopy(self._list_items[index.row()]) def data(self, index, role=QtCore.Qt.DisplayRole): """Return data for the given index and role.""" if not index.isValid(): return None row, col = index.row(), index.column() + + if col == 0 and role == QtCore.Qt.DisplayRole: # QtCore.Qt.DisplayRole: + dev_name = self._list_items[row].get("name", "") + return self._validation_status.get(dev_name, ValidationStatus.PENDING) + key = self.headers[col] - value = self._device_config[row].get(key) + value = self._list_items[row].get(key) if role == QtCore.Qt.DisplayRole: if key in ("enabled", "readOnly"): @@ -210,7 +288,7 @@ def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole: if not self._checkable_columns_enabled.get(key, True): return False # ignore changes if column is disabled - self._device_config[row][key] = value == QtCore.Qt.Checked + self._list_items[row][key] = value == QtCore.Qt.Checked self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole]) return True return False @@ -219,87 +297,115 @@ def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: ############ Public methods ######## #################################### - def get_device_config(self) -> list[dict]: - """Return the current device config (with checkbox updates applied).""" + def get_device_config(self) -> dict[str, dict]: + """Method to get the device configuration.""" return self._device_config - def set_checkbox_enabled(self, column_name: str, enabled: bool): + def add_device_configs(self, device_configs: dict[str, dict]): """ - Enable/Disable the checkbox column. + Add devices to the model. Args: - column_name (str): The name of the column to modify. - enabled (bool): Whether the checkbox should be enabled or disabled. - """ - if column_name in self._checkable_columns_enabled: - self._checkable_columns_enabled[column_name] = enabled - col = self.headers.index(column_name) - top_left = self.index(0, col) - bottom_right = self.index(self.rowCount() - 1, col) - self.dataChanged.emit( - top_left, bottom_right, [QtCore.Qt.CheckStateRole, QtCore.Qt.DisplayRole] - ) - - def set_device_config(self, device_config: list[dict]): + device_configs (dict[str, dict]): A dictionary of device configurations to add. + """ + already_in_list = [] + for k, cfg in device_configs.items(): + if k in self._device_config: + logger.warning(f"Device {k} already exists in the model.") + already_in_list.append(k) + continue + self._device_config[k] = cfg + new_list_cfg = copy.deepcopy(cfg) + new_list_cfg["name"] = k + row = len(self._list_items) + self.beginInsertRows(QtCore.QModelIndex(), row, row) + self._list_items.append(new_list_cfg) + self.endInsertRows() + for k in already_in_list: + device_configs.pop(k) + self.device_configs_added.emit(device_configs) + + def set_device_config(self, device_configs: dict[str, dict]): """ Replace the device config. Args: - device_config (list[dict]): The new device config to set. + device_config (dict[str, dict]): The new device config to set. """ + diff_names = set(device_configs.keys()) - set(self._device_config.keys()) self.beginResetModel() - self._device_config = list(device_config) + self._device_config.clear() + self._list_items.clear() + for k, cfg in device_configs.items(): + self._device_config[k] = cfg + new_list_cfg = copy.deepcopy(cfg) + new_list_cfg["name"] = k + self._list_items.append(new_list_cfg) self.endResetModel() + self.devices_removed.emit(diff_names) + self.device_configs_added.emit(device_configs) - @SafeSlot(dict) - def add_device(self, device: dict): - """ - Add an extra device to the device config at the bottom. - - Args: - device (dict): The device configuration to add. - """ - row = len(self._device_config) - self.beginInsertRows(QtCore.QModelIndex(), row, row) - self._device_config.append(device) - self.endInsertRows() - - @SafeSlot(int) - def remove_device_by_row(self, row: int): + def remove_device_configs(self, device_configs: dict[str, dict]): """ - Remove one device row by index. This maps to the row to the source of the data model + Remove devices from the model. Args: - row (int): The index of the device row to remove. - """ - if 0 <= row < len(self._device_config): + device_configs (dict[str, dict]): A dictionary of device configurations to remove. + """ + removed = [] + for k in device_configs.keys(): + if k not in self._device_config: + logger.warning(f"Device {k} does not exist in the model.") + continue + new_cfg = self._device_config.pop(k) + new_cfg["name"] = k + row = self._list_items.index(new_cfg) self.beginRemoveRows(QtCore.QModelIndex(), row, row) - self._device_config.pop(row) + self._list_items.pop(row) self.endRemoveRows() + removed.append(k) + self.devices_removed.emit(removed) - @SafeSlot(list) - def remove_devices_by_rows(self, rows: list[int]): + def clear_table(self): """ - Remove multiple device rows by their indices. - - Args: - rows (list[int]): The indices of the device rows to remove. + Clear the table. """ - for row in sorted(rows, reverse=True): - self.remove_device_by_row(row) + device_names = list(self._device_config.keys()) + self.beginResetModel() + self._device_config.clear() + self._list_items.clear() + self.endResetModel() + self.devices_removed.emit(device_names) - @SafeSlot(str) - def remove_device_by_name(self, name: str): + def update_validation_status(self, device_name: str, status: int | ValidationStatus): """ - Remove one device row by name. + Handle device status changes. Args: - name (str): The name of the device to remove. - """ - for row, device in enumerate(self._device_config): - if device.get("name") == name: - self.remove_device_by_row(row) + device_name (str): The name of the device. + status (int): The new status of the device. + """ + if isinstance(status, int): + status = ValidationStatus(status) + if device_name not in self._device_config: + logger.warning( + f"Device {device_name} not found in device_config dict {self._device_config}" + ) + return + self._validation_status[device_name] = status + row = None + for ii, item in enumerate(self._list_items): + if item["name"] == device_name: + row = ii break + if row is None: + logger.warning( + f"Device {device_name} not found in device_status dict {self._validation_status}" + ) + return + # Emit dataChanged for column 0 (status column) + index = self.index(row, 0) + self.dataChanged.emit(index, index, [QtCore.Qt.DisplayRole]) class BECTableView(QtWidgets.QTableView): @@ -319,12 +425,7 @@ def keyPressEvent(self, event) -> None: if not proxy_indexes: return - # Get unique rows (proxy indices) in reverse order so removal indexes stay valid - proxy_rows = sorted({idx.row() for idx in proxy_indexes}, reverse=True) - # Map to source model rows - source_rows = [ - self.model().mapToSource(self.model().index(row, 0)).row() for row in proxy_rows - ] + source_rows = self._get_source_rows(proxy_indexes) model: DeviceTableModel = self.model().sourceModel() # access underlying model # Delegate confirmation and removal to helper @@ -332,14 +433,28 @@ def keyPressEvent(self, event) -> None: if not removed: return + def _get_source_rows(self, proxy_indexes: list[QtWidgets.QModelIndex]) -> list[int]: + """ + Map proxy model indices to source model row indices. + + Args: + proxy_indexes (list[QModelIndex]): List of proxy model indices. + + Returns: + list[int]: List of source model row indices. + """ + proxy_rows = sorted({idx for idx in proxy_indexes}, reverse=True) + source_rows = [self.model().mapToSource(idx).row() for idx in proxy_rows] + return list(set(source_rows)) + def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool: """ Prompt the user to confirm removal of rows and remove them from the model if accepted. Returns True if rows were removed, False otherwise. """ - cfg = model.get_device_config() - names = [str(cfg[r].get("name", "")) for r in sorted(source_rows)] + configs = [model._list_items[r] for r in sorted(source_rows)] + names = [cfg.get("name", "") for cfg in configs] msg = QtWidgets.QMessageBox(self) msg.setIcon(QtWidgets.QMessageBox.Warning) @@ -354,8 +469,8 @@ def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[in res = msg.exec_() if res == QtWidgets.QMessageBox.Ok: - model.remove_devices_by_rows(source_rows) - # TODO add signal for removed devices + configs_to_be_removed = {model._device_config[name] for name in names} + model.remove_device_configs(configs_to_be_removed) return True return False @@ -367,7 +482,7 @@ def __init__(self, parent=None): self._hidden_rows = set() self._filter_text = "" self._enable_fuzzy = True - self._filter_columns = [0, 1] # name and deviceClass for search + self._filter_columns = [1, 2] # name and deviceClass for search def hide_rows(self, row_indices: list[int]): """ @@ -431,11 +546,12 @@ def filterAcceptsRow(self, source_row: int, source_parent) -> bool: class DeviceTableView(BECWidget, QtWidgets.QWidget): """Device Table View for the device manager.""" - selected_device = QtCore.Signal(dict) + selected_device = QtCore.Signal(dict) # Selected device configuration dict[str,dict] + device_configs_added = QtCore.Signal(dict) # Dict[str, dict] of configs that were added + devices_removed = QtCore.Signal(list) # List of strings with device names that were removed RPC = False PLUGIN = False - devices_removed = QtCore.Signal(list) def __init__(self, parent=None, client=None): super().__init__(client=client, parent=parent, theme_update=True) @@ -452,6 +568,10 @@ def __init__(self, parent=None, client=None): self.layout.addLayout(self.search_controls) self.layout.addWidget(self.table) + # Connect signals + self._model.devices_removed.connect(self.devices_removed.emit) + self._model.device_configs_added.connect(self.device_configs_added.emit) + def _setup_search(self): """Create components related to the search functionality""" @@ -492,43 +612,48 @@ def _setup_table_view(self) -> None: """Setup the table view.""" # Model + Proxy self.table = BECTableView(self) - self.model = DeviceTableModel(parent=self.table) + self._model = DeviceTableModel(parent=self.table) self.proxy = DeviceFilterProxyModel(parent=self.table) - self.proxy.setSourceModel(self.model) + self.proxy.setSourceModel(self._model) self.table.setModel(self.proxy) self.table.setSortingEnabled(True) # Delegates - self.checkbox_delegate = CenterCheckBoxDelegate(self.table) + colors = get_accent_colors() + self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors) self.wrap_delegate = WrappingTextDelegate(self.table) self.tool_tip_delegate = DictToolTipDelegate(self.table) - self.table.setItemDelegateForColumn(0, self.tool_tip_delegate) # name - self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # deviceClass - self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn(3, self.wrap_delegate) # deviceTags - self.table.setItemDelegateForColumn(4, self.checkbox_delegate) # enabled - self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # readOnly + self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors) + self.table.setItemDelegateForColumn(0, self.validated_delegate) # ValidationStatus + self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name + self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass + self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority + self.table.setItemDelegateForColumn(4, self.wrap_delegate) # deviceTags + self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # enabled + self.table.setItemDelegateForColumn(6, self.checkbox_delegate) # readOnly # Column resize policies - # TODO maybe we need here a flexible header options as deviceClass - # may get quite long for beamlines plugin repos header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents) # name - header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # deviceClass - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority - header.setSectionResizeMode(3, QtWidgets.QHeaderView.Stretch) # deviceTags - header.setSectionResizeMode(4, QtWidgets.QHeaderView.Fixed) # enabled - header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) # readOnly - - self.table.setColumnWidth(3, 70) - self.table.setColumnWidth(4, 70) + header.setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed) # ValidationStatus + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # name + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # deviceClass + header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority + header.setSectionResizeMode(4, QtWidgets.QHeaderView.Stretch) # deviceTags + header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) # enabled + header.setSectionResizeMode(6, QtWidgets.QHeaderView.Fixed) # readOnly + + self.table.setColumnWidth(0, 25) + self.table.setColumnWidth(5, 70) + self.table.setColumnWidth(6, 70) # Ensure column widths stay fixed - header.setMinimumSectionSize(70) + header.setMinimumSectionSize(25) header.setDefaultSectionSize(90) # Enable resizing of column - header.sectionResized.connect(self.on_table_resized) + self._geometry_resize_proxy = BECSignalProxy( + header.geometriesChanged, rateLimit=10, slot=self._on_table_resized + ) # Selection behavior self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) @@ -539,28 +664,26 @@ def _setup_table_view(self) -> None: # QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0)) - def device_config(self) -> list[dict]: + def get_device_config(self) -> dict[str, dict]: """Get the device config.""" - return self.model.get_device_config() + return self._model.get_device_config() def apply_theme(self, theme: str | None = None): self.checkbox_delegate.apply_theme(theme) + self.validated_delegate.apply_theme(theme) ###################################### ########### Slot API ################# ###################################### - @SafeSlot(int, int, int) - def on_table_resized(self, column, old_width, new_width): + @SafeSlot() + def _on_table_resized(self, *args): """Handle changes to the table column resizing.""" - if column != len(self.model.headers) - 1: - return - - for row in range(self.table.model().rowCount()): - index = self.table.model().index(row, column) - delegate = self.table.itemDelegate(index) - option = QtWidgets.QStyleOptionViewItem() - height = delegate.sizeHint(option, index).height() + option = QtWidgets.QStyleOptionViewItem() + model = self.table.model() + for row in range(model.rowCount()): + index = model.index(row, 4) + height = self.wrap_delegate.sizeHint(option, index).height() self.table.setRowHeight(row, height) @SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection) @@ -582,86 +705,106 @@ def _on_selection_changed( source_indexes = [self.proxy.mapToSource(idx) for idx in selected_indexes] source_rows = {idx.row() for idx in source_indexes} - # Ignore if multiple are selected - if len(source_rows) > 1: - self.selected_device.emit({}) - return - - # Get the single row - (row,) = source_rows - source_index = self.model.index(row, 0) # pick column 0 or whichever - device = self.model.get_row_data(source_index) - self.selected_device.emit(device) - - @SafeSlot(QtCore.QModelIndex) - def _on_row_selected(self, index: QtCore.QModelIndex): - """Handle row selection in the device table.""" - if not index.isValid(): - return - source_index = self.proxy.mapToSource(index) - device = self.model.get_device_at_index(source_index) - self.selected_device.emit(device) + configs = [copy.deepcopy(self._model._list_items[r]) for r in sorted(source_rows)] + names = [cfg.pop("name") for cfg in configs] + selected_cfgs = {name: cfg for name, cfg in zip(names, configs)} + self.selected_device.emit(selected_cfgs) ###################################### ##### Ext. Slot API ################# ###################################### - @SafeSlot(list) - def set_device_config(self, config: list[dict]): + @SafeSlot(dict) + def set_device_config(self, device_configs: dict[str, dict]): """ Set the device config. Args: - config (list[dict]): The device config to set. + config (dict[str,dict]): The device config to set. """ - self.model.set_device_config(config) + self._model.set_device_config(device_configs) @SafeSlot() - def clear_device_config(self): + def clear_device_configs(self): + """Clear the device configs.""" + self._model.clear_table() + + @SafeSlot(dict) + def add_device_configs(self, device_configs: dict[str, dict]): """ - Clear the device config. + Add devices to the config. + + Args: + device_configs (dict[str, dict]): The device configs to add. """ - self.model.set_device_config([]) + self._model.add_device_configs(device_configs) @SafeSlot(dict) - def add_device(self, device: dict): + def remove_device_configs(self, device_configs: dict[str, dict]): """ - Add a device to the config. + Remove devices from the config. Args: - device (dict): The device to add. + device_configs (dict[str, dict]): The device configs to remove. """ - self.model.add_device(device) + self._model.remove_device_configs(device_configs) - @SafeSlot(int) @SafeSlot(str) - def remove_device(self, dev: int | str): + def remove_device(self, device_name: str): """ - Remove the device from the config either by row id, or device name. + Remove a device from the config. Args: - dev (int | str): The device to remove, either by row id or device name. + device_name (str): The name of the device to remove. """ - if isinstance(dev, int): - # TODO test this properly, check with proxy index and source index - # Use the proxy model to map to the correct row - model_source_index = self.table.model().mapToSource(self.table.model().index(dev, 0)) - self.model.remove_device_by_row(model_source_index.row()) - return - if isinstance(dev, str): - self.model.remove_device_by_name(dev) + cfg = self._model._device_config.get(device_name, None) + if cfg is None: + logger.warning(f"Device {device_name} not found in device_config dict") return + self._model.remove_device_configs({device_name: cfg}) + + @SafeSlot(str, int) + def update_device_validation( + self, device_name: str, validation_status: int | ValidationStatus + ) -> None: + """ + Update the validation status of a device. + + Args: + device_name (str): The name of the device. + validation_status (int | ValidationStatus): The new validation status. + """ + self._model.update_validation_status(device_name, validation_status) if __name__ == "__main__": import sys + import numpy as np from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) window = DeviceTableView() + layout.addWidget(window) + # QPushButton + button = QtWidgets.QPushButton("Test status_update") + layout.addWidget(button) + + def _button_clicked(): + names = list(window._model._device_config.keys()) + for name in names: + window.update_device_validation( + name, ValidationStatus.VALID if np.random.rand() > 0.5 else ValidationStatus.FAILED + ) + + button.clicked.connect(_button_clicked) # pylint: disable=protected-access config = window.client.device_manager._get_redis_device_config() - window.set_device_config(config) - window.show() + names = [cfg.pop("name") for cfg in config] + config_dict = {name: cfg for name, cfg in zip(names, config)} + window.set_device_config(config_dict) + widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py index 155f82d96..846c84ee4 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -2,17 +2,23 @@ from __future__ import annotations +import traceback + import yaml +from bec_lib.logger import bec_logger from qtpy import QtCore, QtWidgets from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors, get_theme_palette from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +logger = bec_logger.logger + class DMConfigView(BECWidget, QtWidgets.QWidget): def __init__(self, parent=None, client=None): - super().__init__(client=client, parent=parent) + super().__init__(client=client, parent=parent, theme_update=True) self.stacked_layout = QtWidgets.QStackedLayout() self.stacked_layout.setContentsMargins(0, 0, 0, 0) self.stacked_layout.setSpacing(0) @@ -35,12 +41,11 @@ def _customize_monaco(self): self.monaco_editor.set_minimap_enabled(False) # self.monaco_editor.setFixedHeight(600) self.monaco_editor.set_readonly(True) + self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False) + self.monaco_editor.editor.set_line_numbers_mode("off") def _customize_overlay(self): self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - self._overlay_widget.setStyleSheet( - "background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);" - ) self._overlay_widget.setAutoFillBackground(True) self._overlay_widget.setSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding @@ -49,13 +54,20 @@ def _customize_overlay(self): @SafeSlot(dict) def on_select_config(self, device: dict): """Handle selection of a device from the device table.""" - if not device: + if len(device) != 1: text = "" self.stacked_layout.setCurrentWidget(self._overlay_widget) else: - text = yaml.dump(device, default_flow_style=False) - self.stacked_layout.setCurrentWidget(self.monaco_editor) + try: + text = yaml.dump(device, default_flow_style=False) + self.stacked_layout.setCurrentWidget(self.monaco_editor) + except Exception: + content = traceback.format_exc() + logger.error(f"Error converting device to YAML:\n{content}") + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) self.monaco_editor.set_readonly(False) # Enable editing + text = text.rstrip() self.monaco_editor.set_text(text) self.monaco_editor.set_readonly(True) # Disable editing again diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py new file mode 100644 index 000000000..7b128992b --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -0,0 +1,128 @@ +"""Module to visualize the docstring of a device class.""" + +from __future__ import annotations + +import inspect +import re +import traceback + +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import get_plugin_class, plugin_package_name +from bec_lib.utils.rpc_utils import rgetattr +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + +try: + import ophyd + import ophyd_devices + + READY_TO_VIEW = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + ophyd = None + + +class DocstringView(QtWidgets.QTextEdit): + def __init__(self, parent: QtWidgets.QWidget | None = None): + super().__init__(parent) + self.setReadOnly(True) + self.setFocusPolicy(QtCore.Qt.NoFocus) + if not READY_TO_VIEW: + self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.") + self.setEnabled(False) + return + + def _format_docstring(self, doc: str | None) -> str: + if not doc: + return "No docstring available." + + # Escape HTML + doc = doc.replace("&", "&").replace("<", "<").replace(">", ">") + + # Remove leading/trailing blank lines from the entire docstring + lines = [line.rstrip() for line in doc.splitlines()] + while lines and lines[0].strip() == "": + lines.pop(0) + while lines and lines[-1].strip() == "": + lines.pop() + doc = "\n".join(lines) + + # Improved regex: match section header + all following indented lines + section_regex = re.compile( + r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b(?:\n([ \t]+.*))*", + re.MULTILINE, + ) + + def strip_section(match: re.Match) -> str: + # Capture all lines in the match + block = match.group(0) + lines = block.splitlines() + # Remove leading/trailing empty lines within the section + lines = [line for line in lines if line.strip() != ""] + return "\n".join(lines) + + doc = section_regex.sub(strip_section, doc) + + # Highlight section titles + doc = re.sub( + r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b", r"\1", doc + ) + + # Convert indented blocks to
 and strip leading/trailing newlines
+        def pre_block(match: re.Match) -> str:
+            text = match.group(0).strip("\n")
+            return f"
{text}
" + + doc = re.sub(r"(?m)(?:\n[ \t]+.*)+", pre_block, doc) + + # Replace remaining newlines with
and collapse multiple
+ doc = doc.replace("\n", "
") + doc = re.sub(r"(
)+", r"
", doc) + doc = doc.strip("
") + + return f"
{doc}
" + + def _set_text(self, text: str): + self.setReadOnly(False) + self.setMarkdown(text) + # self.setHtml(self._format_docstring(text)) + self.setReadOnly(True) + + @SafeSlot(dict) + def on_select_config(self, device: dict): + if len(device) != 1: + self._set_text("") + return + k = next(iter(device)) + device_class = device[k].get("deviceClass", "") + self.set_device_class(device_class) + + @SafeSlot(str) + def set_device_class(self, device_class_str: str) -> None: + docstring = "" + if not READY_TO_VIEW: + return + try: + module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd]) + docstring = inspect.getdoc(module_cls) + self._set_text(docstring or "No docstring available.") + except Exception: + content = traceback.format_exc() + logger.error(f"Error retrieving docstring for {device_class_str}: {content}") + self._set_text(f"Error retrieving docstring for {device_class_str}") + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + config_view = DocstringView() + config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera") + config_view.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py index a2a44f42a..ef9e9fecf 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -1,18 +1,24 @@ -"""Module to run a static test for the current config and see if it is valid.""" +"""Module to run a static tests for devices from a yaml config.""" from __future__ import annotations import enum +import re +import traceback +from html import escape +from typing import TYPE_CHECKING import bec_lib from bec_lib.logger import bec_logger from bec_qthemes import material_icon +from ophyd import status from qtpy import QtCore, QtGui, QtWidgets from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget READY_TO_TEST = False @@ -28,305 +34,380 @@ ophyd_devices = None bec_server = None +if TYPE_CHECKING: # pragma no cover + try: + from ophyd_devices.utils.static_device_test import StaticDeviceTest + except ImportError: + StaticDeviceTest = None + class ValidationStatus(int, enum.Enum): """Validation status for device configurations.""" - UNKNOWN = 0 # colors.default - ERROR = 1 # colors.emergency - VALID = 2 # colors.highlight - CANT_CONNECT = 3 # colors.warning - CONNECTED = 4 # colors.success + PENDING = 0 # colors.default + VALID = 1 # colors.highlight + FAILED = 2 # colors.emergency -class DeviceValidationListItem(QtWidgets.QWidget): - """Custom list item widget showing device name and validation status.""" +class DeviceValidationResult(QtCore.QObject): + """Simple object to inject validation signals into QRunnable.""" + + # Device validation signal, device_name, ValidationStatus as int, error message or '' + device_validated = QtCore.Signal(str, bool, str) - status_changed = QtCore.Signal(int) # Signal emitted when status changes -> ValidationStatus - # Signal emitted when device was validated with name, success, msg - device_validated = QtCore.Signal(str, str) + +class DeviceValidationRunnable(QtCore.QRunnable): + """Runnable for validating a device configuration.""" def __init__( self, - device_config: dict[str, dict], - status: ValidationStatus, - status_icons: dict[ValidationStatus, QtGui.QPixmap], - validate_icon: QtGui.QPixmap, - parent=None, - static_device_test=None, + device_name: str, + config: dict, + static_device_test: StaticDeviceTest | None, + connect: bool = False, ): - super().__init__(parent) - if len(device_config.keys()) > 1: - logger.warning( - f"Multiple devices found for config: {list(device_config.keys())}, using first one" - ) + """ + Initialize the device validation runnable. + + Args: + device_name (str): The name of the device to validate. + config (dict): The configuration dictionary for the device. + static_device_test (StaticDeviceTest): The static device test instance. + connect (bool, optional): Whether to connect to the device. Defaults to False. + """ + super().__init__() + self.device_name = device_name + self.config = config + self._connect = connect self._static_device_test = static_device_test - self.device_name = list(device_config.keys())[0] + self.signals = DeviceValidationResult() + + def run(self): + """Run method for device validation.""" + if self._static_device_test is None: + logger.error( + f"Ophyd devices or bec_server not available, cannot run validation for device {self.device_name}." + ) + return + try: + self._static_device_test.config = {self.device_name: self.config} + results = self._static_device_test.run_with_list_output(connect=self._connect) + success = results[0].success + msg = results[0].message + self.signals.device_validated.emit(self.device_name, success, msg) + except Exception: + content = traceback.format_exc() + logger.error(f"Validation failed for device {self.device_name}. Exception: {content}") + self.signals.device_validated.emit(self.device_name, False, content) + + +class ValidationListItem(QtWidgets.QWidget): + """Custom list item widget showing device name and validation status.""" + + def __init__(self, device_name: str, device_config: dict, parent=None): + """ + Initialize the validation list item. + + Args: + device_name (str): The name of the device. + device_config (dict): The configuration of the device. + validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status. + parent (QtWidgets.QWidget, optional): The parent widget. + """ + super().__init__(parent) + self.main_layout = QtWidgets.QHBoxLayout(self) + self.main_layout.setContentsMargins(2, 2, 2, 2) + self.main_layout.setSpacing(4) + self.device_name = device_name self.device_config = device_config - self.status: ValidationStatus = status - colors = get_accent_colors() - self._status_icon = status_icons - self._validate_icon = validate_icon + self.validation_msg = "Validation in progress..." self._setup_ui() - self._update_status_indicator() def _setup_ui(self): """Setup the UI for the list item.""" - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(4, 4, 4, 4) - - # Device name label - self.name_label = QtWidgets.QLabel(self.device_name) - self.name_label.setStyleSheet("font-weight: bold;") - layout.addWidget(self.name_label) - - # Make sure status is on the right - layout.addStretch() - self.request_validation_button = QtWidgets.QPushButton("Validate") - self.request_validation_button.setIcon(self._validate_icon) - if self._static_device_test is None: - self.request_validation_button.setDisabled(True) - else: - self.request_validation_button.clicked.connect(self.on_request_validation) - # self.request_validation_button.setVisible(False) -> Hide it?? - layout.addWidget(self.request_validation_button) - # Status indicator - self.status_indicator = QtWidgets.QLabel() - self._update_status_indicator() - layout.addWidget(self.status_indicator) + label = QtWidgets.QLabel(self.device_name) + self.main_layout.addWidget(label) + self.main_layout.addStretch() + self._spinner = SpinnerWidget(parent=self) + self._spinner.speed = 80 + self._spinner.setFixedSize(24, 24) + self.main_layout.addWidget(self._spinner) + self._base_style = "font-weight: bold;" + self.setStyleSheet(self._base_style) + self._start_spinner() + + def _start_spinner(self): + """Start the spinner animation.""" + self._spinner.start() + QtWidgets.QApplication.processEvents() + + def _stop_spinner(self): + """Stop the spinner animation.""" + self._spinner.stop() + self._spinner.setVisible(False) @SafeSlot() - def on_request_validation(self): - """Handle validate button click.""" - if self._static_device_test is None: - logger.warning("Static device test not available.") - return - self._static_device_test.config = self.device_config - # TODO logic if connect is allowed - ret = self._static_device_test.run_with_list_output(connect=False)[0] - if ret.success: - self.set_status(ValidationStatus.VALID) - else: - self.set_status(ValidationStatus.ERROR) - self.device_validated.emit(ret.name, ret.message) - - def _update_status_indicator(self): - """Update the status indicator color based on validation status.""" - self.status_indicator.setPixmap(self._status_icon[self.status]) - - def set_status(self, status: ValidationStatus): - """Update the validation status.""" - self.status = status - self._update_status_indicator() - self.status_changed.emit(self.status) - - def get_status(self) -> ValidationStatus: - """Get the current validation status.""" - return self.status + def on_validation_restart(self): + """Handle validation restart.""" + self.validation_msg = "" + self._start_spinner() + self.setStyleSheet("") # Check if this works as expected + + @SafeSlot(str) + def on_validation_failed(self, error_msg: str): + """Handle validation failure.""" + self.validation_msg = error_msg + colors = get_accent_colors() + self._stop_spinner() + self.main_layout.removeWidget(self._spinner) + self._spinner.deleteLater() + label = QtWidgets.QLabel("") + icon = material_icon("error", color=colors.emergency, size=(24, 24)) + label.setPixmap(icon) + self.main_layout.addWidget(label) -class DeviceManagerOphydTest(BECWidget, QtWidgets.QWidget): +class DMOphydTest(BECWidget, QtWidgets.QWidget): + """Widget to test device configurations using ophyd devices.""" - config_changed = QtCore.Signal( - dict, dict - ) # Signal emitted when the device config changed, new_config, old_config + # Signal to emit the validation status of a device + device_validated = QtCore.Signal(str, int) def __init__(self, parent=None, client=None): super().__init__(parent=parent, client=client) if not READY_TO_TEST: - self._set_disabled() - static_device_test = None + self.setDisabled(True) + self.static_device_test = None else: from ophyd_devices.utils.static_device_test import StaticDeviceTest - static_device_test = StaticDeviceTest(config_dict={}) - self._static_device_test = static_device_test - self._device_config: dict[str, dict] = {} + self.static_device_test = StaticDeviceTest(config_dict={}) + self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} + self._thread_pool = QtCore.QThreadPool.globalInstance() + self._main_layout = QtWidgets.QVBoxLayout(self) self._main_layout.setContentsMargins(0, 0, 0, 0) self._main_layout.setSpacing(4) - # Setup icons - colors = get_accent_colors() - self._validate_icon = material_icon( - icon_name="play_arrow", color=colors.default, filled=True - ) - self._status_icons = { - ValidationStatus.UNKNOWN: material_icon( - icon_name="circle", size=(12, 12), color=colors.default, filled=True - ), - ValidationStatus.ERROR: material_icon( - icon_name="circle", size=(12, 12), color=colors.emergency, filled=True - ), - ValidationStatus.VALID: material_icon( - icon_name="circle", size=(12, 12), color=colors.highlight, filled=True - ), - ValidationStatus.CANT_CONNECT: material_icon( - icon_name="circle", size=(12, 12), color=colors.warning, filled=True - ), - ValidationStatus.CONNECTED: material_icon( - icon_name="circle", size=(12, 12), color=colors.success, filled=True - ), - } - - self.setLayout(self._main_layout) - - # splitter + # We add a splitter between the list and the text box self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) self._main_layout.addWidget(self.splitter) - # Add custom list - self.setup_device_validation_list() - - # Setup text box - self.setup_text_box() + self._setup_list_ui() + self._setup_textbox_ui() + def _setup_list_ui(self): + """Setup the list UI.""" + self._list_widget = QtWidgets.QListWidget(self) + self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.splitter.addWidget(self._list_widget) # Connect signals - self.config_changed.connect(self.on_config_updated) - - @SafeSlot(list) - def on_device_config_update(self, config: list[dict]): - old_cfg = self._device_config - self._device_config = self._compile_device_config_list(config) - self.config_changed.emit(self._device_config, old_cfg) - - def _compile_device_config_list(self, config: list[dict]) -> dict[str, dict]: - return {dev["name"]: {k: v for k, v in dev.items() if k != "name"} for dev in config} - - @SafeSlot(dict, dict) - def on_config_updated(self, new_config: dict, old_config: dict): - """Handle config updates and refresh the validation list.""" - # Find differences for potential re-validation - diffs = self._find_diffs(new_config, old_config) - # Check diff first - for diff in diffs: - if not diff: - continue - if len(diff) > 1: - logger.warning(f"Multiple devices found in diff: {diff}, using first one") - name = list(diff.keys())[0] - if name in self.client.device_manager.devices: - status = ValidationStatus.CONNECTED - else: - status = ValidationStatus.UNKNOWN - if self.get_device_status(diff) is None: - self.add_device(diff, status) - else: - self.update_device_status(diff, status) - - def _find_diffs(self, new_config: dict, old_config: dict) -> list[dict]: + self._list_widget.currentItemChanged.connect(self._on_current_item_changed) + + def _setup_textbox_ui(self): + """Setup the text box UI.""" + self._text_box = QtWidgets.QTextEdit(self) + self._text_box.setReadOnly(True) + self._text_box.setFocusPolicy(QtCore.Qt.NoFocus) + self.splitter.addWidget(self._text_box) + + @SafeSlot(dict) + def add_device_configs(self, device_configs: dict[str, dict]) -> None: + """Receive an update with device configs. + + Args: + device_configs (dict[str, dict]): The updated device configurations. + """ + for device_name, device_config in device_configs.items(): + if device_name in self._device_list_items: + logger.error(f"Device {device_name} is already in the list.") + return + item = QtWidgets.QListWidgetItem(self._list_widget) + widget = ValidationListItem(device_name=device_name, device_config=device_config) + + # wrap it in a QListWidgetItem + item.setSizeHint(widget.sizeHint()) + self._list_widget.addItem(item) + self._list_widget.setItemWidget(item, widget) + self._device_list_items[device_name] = item + self._run_device_validation(widget) + + @SafeSlot(dict) + def remove_device_configs(self, device_configs: dict[str, dict]) -> None: + """Remove device configs from the list. + + Args: + device_name (str): The name of the device to remove. """ - Return list of keys/paths where d1 and d2 differ. This goes recursively through the dictionary. + for device_name in device_configs.keys(): + if device_name not in self._device_list_items: + logger.warning(f"Device {device_name} not found in list.") + return + self._remove_list_item(device_name) + + def _remove_list_item(self, device_name: str): + """Remove a device from the list.""" + # Get the list item + item = self._device_list_items.pop(device_name) + + # Retrieve the custom widget attached to the item + widget = self._list_widget.itemWidget(item) + if widget is not None: + widget.deleteLater() # clean up custom widget + + # Remove the item from the QListWidget + row = self._list_widget.row(item) + self._list_widget.takeItem(row) + + def _run_device_validation(self, widget: ValidationListItem): + """ + Run the device validation in a separate thread. + + Args: + widget (ValidationListItem): The widget to validate. + """ + if not READY_TO_TEST: + logger.error("Ophyd devices or bec_server not available, cannot run validation.") + return + if ( + widget.device_name in self.client.device_manager.devices + ): # TODO and config has to be exact the same.. + self._on_device_validated( + widget.device_name, + ValidationStatus.VALID, + f"Device {widget.device_name} is already in active config", + ) + return + runnable = DeviceValidationRunnable( + device_name=widget.device_name, + config=widget.device_config, + static_device_test=self.static_device_test, + connect=False, + ) + runnable.signals.device_validated.connect(self._on_device_validated) + self._thread_pool.start(runnable) + + @SafeSlot(str, bool, str) + def _on_device_validated(self, device_name: str, success: bool, message: str): + """Handle the device validation result. Args: - new_config: The first dictionary to compare. - old_config: The second dictionary to compare. + device_name (str): The name of the device. + success (bool): Whether the validation was successful. + message (str): The validation message. """ - diffs = [] - keys = set(new_config.keys()) | set(old_config.keys()) - for k in keys: - if k not in old_config: # New device - diffs.append({k: new_config[k]}) - continue - if k not in new_config: # Removed device - diffs.append({k: old_config[k]}) - continue - # Compare device config if exists in both - v1, v2 = old_config[k], new_config[k] - if isinstance(v1, dict) and isinstance(v2, dict): - if self._find_diffs(v2, v1): # recurse: something inside changed - diffs.append({k: new_config[k]}) - elif v1 != v2: - diffs.append({k: new_config[k]}) - return diffs - - def setup_device_validation_list(self): - """Setup the device validation list.""" - # Create the custom validation list widget - self.validation_list = QtWidgets.QListWidget() - self.validation_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) - self.splitter.addWidget(self.validation_list) - # self._main_layout.addWidget(self.validation_list) - - def setup_text_box(self): - """Setup the text box for device validation messages.""" - self.validation_text_box = QtWidgets.QTextEdit() - self.validation_text_box.setReadOnly(True) - self.splitter.addWidget(self.validation_text_box) - # self._main_layout.addWidget(self.validation_text_box) - - @SafeSlot(str, str) - def on_device_validated(self, device_name: str, message: str): - """Handle device validation results.""" - text = f"Device {device_name} was validated. Message: {message}" - self.validation_text_box.setText(text) - - def _set_disabled(self) -> None: - """Disable the full view""" - self.setDisabled(True) - - def add_device( - self, device_config: dict[str, dict], status: ValidationStatus = ValidationStatus.UNKNOWN + logger.info(f"Device {device_name} validation result: {success}, message: {message}") + item = self._device_list_items.get(device_name, None) + if not item: + logger.error(f"Device {device_name} not found in the list.") + return + if success: + self._remove_list_item(device_name=device_name) + self.device_validated.emit(device_name, ValidationStatus.VALID.value) + else: + widget: ValidationListItem = self._list_widget.itemWidget(item) + widget.on_validation_failed(message) + self.device_validated.emit(device_name, ValidationStatus.FAILED.value) + + def _on_current_item_changed( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem ): - """Add a device to the validation list.""" - # Create the custom widget - item_widget = DeviceValidationListItem( - device_config=device_config, - status=status, - status_icons=self._status_icons, - validate_icon=self._validate_icon, - static_device_test=self._static_device_test, + """Handle the current item change in the list widget. + + Args: + current (QListWidgetItem): The currently selected item. + previous (QListWidgetItem): The previously selected item. + """ + widget: ValidationListItem = self._list_widget.itemWidget(current) + if widget: + try: + formatted_html = self._format_validation_message(widget.validation_msg) + self._text_box.setHtml(formatted_html) + except Exception as e: + logger.error(f"Error formatting validation message: {e}") + self._text_box.setPlainText(widget.validation_msg) + + def _format_validation_message(self, raw_msg: str) -> str: + """Simple HTML formatting for validation messages, wrapping text naturally.""" + if not raw_msg.strip(): + return "Validation in progress..." + if raw_msg == "Validation in progress...": + return "Validation in progress..." + + raw_msg = escape(raw_msg) + + # Split into lines + lines = raw_msg.splitlines() + summary = lines[0] if lines else "Validation Result" + rest = "\n".join(lines[1:]).strip() + + # Split traceback / final ERROR + tb_match = re.search(r"(Traceback.*|ERROR:.*)$", rest, re.DOTALL | re.MULTILINE) + if tb_match: + main_text = rest[: tb_match.start()].strip() + error_detail = tb_match.group().strip() + else: + main_text = rest + error_detail = "" + + # Highlight field names in orange (simple regex for word: Field) + main_text_html = re.sub( + r"(\b\w+\b)(?=: Field required)", + r'\1', + main_text, ) + # Wrap in div for monospace, allowing wrapping + main_text_html = ( + f'
{main_text_html}
' if main_text_html else "" + ) + + # Traceback / error in red + error_html = ( + f'
{error_detail}
' + if error_detail + else "" + ) + + # Summary at top, dark red + html = ( + f'
' + f'
{summary}
' + f"{main_text_html}" + f"{error_html}" + f"
" + ) + return html + + @SafeSlot() + def clear_list(self): + """Clear the device list.""" + self._thread_pool.clear() + if self._thread_pool.waitForDone(2000) is False: # Wait for threads to finish + logger.error("Failed to wait for threads to finish. Removing items from the list.") + self._device_list_items.clear() + self._list_widget.clear() - # Create a list widget item - list_item = QtWidgets.QListWidgetItem() - list_item.setSizeHint(item_widget.sizeHint()) - - # Add item to list and set custom widget - self.validation_list.addItem(list_item) - self.validation_list.setItemWidget(list_item, item_widget) - item_widget.device_validated.connect(self.on_device_validated) - - def update_device_status(self, device_config: dict[str, dict], status: ValidationStatus): - """Update the validation status for a specific device.""" - for i in range(self.validation_list.count()): - item = self.validation_list.item(i) - widget = self.validation_list.itemWidget(item) - if ( - isinstance(widget, DeviceValidationListItem) - and widget.device_config == device_config - ): - widget.set_status(status) - break - - def clear_devices(self): - """Clear all devices from the list.""" - self.validation_list.clear() - - def get_device_status(self, device_config: dict[str, dict]) -> ValidationStatus | None: - """Get the validation status for a specific device.""" - for i in range(self.validation_list.count()): - item = self.validation_list.item(i) - widget = self.validation_list.itemWidget(item) - if ( - isinstance(widget, DeviceValidationListItem) - and widget.device_config == device_config - ): - return widget.get_status() - return None + def remove_device(self, device_name: str): + """Remove a device from the list.""" + item = self._device_list_items.pop(device_name, None) + if item: + self._list_widget.removeItemWidget(item) if __name__ == "__main__": import sys + from bec_lib.bec_yaml_loader import yaml_load + # pylint: disable=ungrouped-imports from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) - device_manager_ophyd_test = DeviceManagerOphydTest() - cfg = device_manager_ophyd_test.client.device_manager._get_redis_device_config() - cfg.append({"name": "Wrong_Device", "type": "test"}) - device_manager_ophyd_test.on_device_config_update(cfg) + device_manager_ophyd_test = DMOphydTest() + config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml" + cfg = yaml_load(config_path) + cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) + device_manager_ophyd_test.add_device_configs(cfg) device_manager_ophyd_test.show() device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test") device_manager_ophyd_test.resize(800, 600) From 17bccb82116101add0d46faebca0ea6c61bc425e Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 3 Sep 2025 09:49:59 +0200 Subject: [PATCH 046/161] chore: update qtmonaco dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d97e46d4a..e97d09547 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,8 @@ dependencies = [ "PySide6==6.9.0", "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", - "qtmonaco~=0.5", "thefuzz~=0.22", + "qtmonaco~=0.7", "darkdetect~=0.8", "PySide6-QtAds==4.4.0", ] From 73731c8142b5ebd20060eb5a159ab52319d6d659 Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 27 Aug 2025 15:13:30 +0200 Subject: [PATCH 047/161] feat: add ListOfExpandableFrames util --- bec_widgets/utils/expandable_frame.py | 32 +++- .../utils/list_of_expandable_frames.py | 133 ++++++++++++++ .../services/device_browser/device_browser.py | 53 ++---- .../services/device_browser/device_browser.ui | 173 +++++++++--------- .../device_browser/device_item/device_item.py | 3 - tests/unit_tests/test_device_browser.py | 32 ++-- 6 files changed, 271 insertions(+), 155 deletions(-) create mode 100644 bec_widgets/utils/list_of_expandable_frames.py diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py index 9f65500e0..138dac274 100644 --- a/bec_widgets/utils/expandable_frame.py +++ b/bec_widgets/utils/expandable_frame.py @@ -1,7 +1,7 @@ from __future__ import annotations from bec_qthemes import material_icon -from qtpy.QtCore import Signal +from qtpy.QtCore import QSize, Signal from qtpy.QtWidgets import ( QApplication, QFrame, @@ -19,7 +19,8 @@ class ExpandableGroupFrame(QFrame): - + broadcast_size_hint = Signal(QSize) + imminent_deletion = Signal() expansion_state_changed = Signal() EXPANDED_ICON_NAME: str = "collapse_all" @@ -31,6 +32,7 @@ def __init__( super().__init__(parent=parent) self._expanded = expanded + self._title_text = f"{title}" self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain) self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self._layout = QVBoxLayout() @@ -49,21 +51,27 @@ def __init__( def _create_title_layout(self, title: str, icon: str): self._title_layout = QHBoxLayout() self._layout.addLayout(self._title_layout) + self._internal_title_layout = QHBoxLayout() + self._title_layout.addLayout(self._internal_title_layout) - self._title = ClickableLabel(f"{title}") + self._title = ClickableLabel() + self._set_title_text(self._title_text) self._title_icon = ClickableLabel() - self._title_layout.addWidget(self._title_icon) - self._title_layout.addWidget(self._title) + self._internal_title_layout.addWidget(self._title_icon) + self._internal_title_layout.addWidget(self._title) self.icon_name = icon self._title.clicked.connect(self.switch_expanded_state) self._title_icon.clicked.connect(self.switch_expanded_state) - self._title_layout.addStretch(1) + self._internal_title_layout.addStretch(1) self._expansion_button = QToolButton() self._update_expansion_icon() self._title_layout.addWidget(self._expansion_button, stretch=1) + def get_title_layout(self) -> QHBoxLayout: + return self._internal_title_layout + def set_layout(self, layout: QLayout) -> None: self._contents.setLayout(layout) self._contents.layout().setContentsMargins(0, 0, 0, 0) # type: ignore @@ -112,6 +120,18 @@ def _set_title_icon(self, icon_name: str): else: self._title_icon.setVisible(False) + @SafeProperty(str) + def title_text(self): # type: ignore + return self._title_text + + @title_text.setter + def title_text(self, title_text: str): + self._title_text = title_text + self._set_title_text(self._title_text) + + def _set_title_text(self, title_text: str): + self._title.setText(title_text) + # Application example if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/utils/list_of_expandable_frames.py b/bec_widgets/utils/list_of_expandable_frames.py new file mode 100644 index 000000000..1eae99a03 --- /dev/null +++ b/bec_widgets/utils/list_of_expandable_frames.py @@ -0,0 +1,133 @@ +import re +from functools import partial +from re import Pattern +from typing import Generic, Iterable, NamedTuple, TypeVar + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QListWidget, QListWidgetItem, QWidget + +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.widgets.control.device_manager.components.available_device_resources._util import ( + SORT_KEY_ROLE, + SortableQListWidgetItem, +) + +logger = bec_logger.logger + + +_EF = TypeVar("_EF", bound=ExpandableGroupFrame) + + +class ListOfExpandableFrames(QListWidget, Generic[_EF]): + def __init__( + self, /, parent: QWidget | None = None, item_class: type[_EF] = ExpandableGroupFrame + ) -> None: + super().__init__(parent) + _Items = NamedTuple("_Items", (("item", QListWidgetItem), ("widget", _EF))) + self.item_tuple = _Items + self._item_class = item_class + self._item_dict: dict[str, _Items] = {} + + def __contains__(self, id: str): + return id in self._item_dict + + def clear(self) -> None: + self._item_dict = {} + return super().clear() + + def add_item(self, id: str, *args, **kwargs) -> tuple[QListWidgetItem, _EF]: + """Adds the specified type of widget as an item. args and kwargs are passed to the constructor. + + Args: + id (str): the key under which to store the list item in the internal dict + + Returns: + The widget created in the addition process + """ + + def _remove_item(item: QListWidgetItem): + self.takeItem(self.row(item)) + del self._item_dict[id] + self.sortItems() + + def _updatesize(item: QListWidgetItem, item_widget: _EF): + item_widget.adjustSize() + item.setSizeHint(QSize(item_widget.width(), item_widget.height())) + + item = SortableQListWidgetItem(self) + item.setData(SORT_KEY_ROLE, id) # used for sorting + + item_widget = self._item_class(*args, **kwargs) + item_widget.expansion_state_changed.connect(partial(_updatesize, item, item_widget)) + item_widget.imminent_deletion.connect(partial(_remove_item, item)) + item_widget.broadcast_size_hint.connect(item.setSizeHint) + + self.addItem(item) + self.setItemWidget(item, item_widget) + self._item_dict[id] = self.item_tuple(item, item_widget) + + item.setSizeHint(item_widget.sizeHint()) + return (item, item_widget) + + def sort_by_key(self, role=SORT_KEY_ROLE, order=Qt.SortOrder.AscendingOrder): + items = [self.takeItem(0) for i in range(self.count())] + items.sort(key=lambda it: it.data(role), reverse=(order == Qt.SortOrder.DescendingOrder)) + + for it in items: + self.addItem(it) + # reattach its custom widget + widget = self.itemWidget(it) + if widget: + self.setItemWidget(it, widget) + + def item_widget_pairs(self): + return self._item_dict.values() + + def widgets(self): + return (i.widget for i in self._item_dict.values()) + + def get_item_widget(self, id: str): + if (item := self._item_dict.get(id)) is None: + return None + return item + + def set_hidden_pattern(self, pattern: Pattern): + self.hide_all() + self._set_hidden(filter(pattern.search, self._item_dict.keys()), False) + + def set_hidden(self, ids: Iterable[str]): + self._set_hidden(ids, True) + + def _set_hidden(self, ids: Iterable[str], hidden: bool): + for id in ids: + if (_item := self._item_dict.get(id)) is not None: + _item.item.setHidden(hidden) + _item.widget.setHidden(hidden) + else: + logger.warning( + f"List {self.__qualname__} does not have an item with ID {id} to hide!" + ) + self.sortItems() + + def hide_all(self): + self.set_hidden_state_on_all(True) + + def unhide_all(self): + self.set_hidden_state_on_all(False) + + def set_hidden_state_on_all(self, hidden: bool): + for _item in self._item_dict.values(): + _item.item.setHidden(hidden) + _item.widget.setHidden(hidden) + self.sortItems() + + @SafeSlot(str) + def update_filter(self, value: str): + if value == "": + return self.unhide_all() + try: + self.set_hidden_pattern(re.compile(value, re.IGNORECASE)) + except Exception: + self.unhide_all() diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index fbe6fe7d5..3bf106ac1 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -11,19 +11,13 @@ from bec_lib.messages import ConfigAction, ScanStatusMessage from bec_qthemes import material_icon from pyqtgraph import SignalProxy -from qtpy.QtCore import QSize, QThreadPool, Signal -from qtpy.QtWidgets import ( - QFileDialog, - QListWidget, - QListWidgetItem, - QToolButton, - QVBoxLayout, - QWidget, -) +from qtpy.QtCore import QThreadPool, Signal +from qtpy.QtWidgets import QFileDialog, QListWidget, QToolButton, QVBoxLayout, QWidget from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.services.device_browser.device_item import DeviceItem from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( @@ -59,7 +53,8 @@ def __init__( self._q_threadpool = QThreadPool() self.ui = None self.init_ui() - self.dev_list: QListWidget = self.ui.device_list + self.dev_list = ListOfExpandableFrames(self, DeviceItem) + self.ui.verticalLayout.addWidget(self.dev_list) self.dev_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel) self.proxy_device_update = SignalProxy( self.ui.filter_input.textChanged, rateLimit=500, slot=self.update_device_list @@ -132,25 +127,15 @@ def on_device_update(self, action: ConfigAction, content: dict) -> None: def init_device_list(self): self.dev_list.clear() - self._device_items: dict[str, QListWidgetItem] = {} with RPCRegister.delayed_broadcast(): for device, device_obj in self.dev.items(): self._add_item_to_list(device, device_obj) def _add_item_to_list(self, device: str, device_obj): - def _updatesize(item: QListWidgetItem, device_item: DeviceItem): - device_item.adjustSize() - item.setSizeHint(QSize(device_item.width(), device_item.height())) - logger.debug(f"Adjusting {item} size to {device_item.width(), device_item.height()}") - - def _remove_item(item: QListWidgetItem): - self.dev_list.takeItem(self.dev_list.row(item)) - del self._device_items[device] - self.dev_list.sortItems() - - item = QListWidgetItem(self.dev_list) - device_item = DeviceItem( + + device_item = self.dev_list.add_item( + id=device, parent=self, device=device, devices=self.dev, @@ -158,18 +143,11 @@ def _remove_item(item: QListWidgetItem): config_helper=self._config_helper, q_threadpool=self._q_threadpool, ) - device_item.expansion_state_changed.connect(partial(_updatesize, item, device_item)) - device_item.imminent_deletion.connect(partial(_remove_item, item)) + self.editing_enabled.connect(device_item.set_editable) self.device_update.connect(device_item.config_update) tooltip = self.dev[device]._config.get("description", "") device_item.setToolTip(tooltip) - device_item.broadcast_size_hint.connect(item.setSizeHint) - item.setSizeHint(device_item.sizeHint()) - - self.dev_list.setItemWidget(item, device_item) - self.dev_list.addItem(item) - self._device_items[device] = item @SafeSlot(dict, dict) def scan_status_changed(self, scan_info: dict, _: dict): @@ -198,20 +176,11 @@ def update_device_list(self, *_) -> None: Either way, the function will filter the devices based on the filter input text and update the device list. """ - filter_text = self.ui.filter_input.text() for device in self.dev: - if device not in self._device_items: + if device not in self.dev_list: # it is possible the device has just been added to the config self._add_item_to_list(device, self.dev[device]) - try: - self.regex = re.compile(filter_text, re.IGNORECASE) - except re.error: - self.regex = None # Invalid regex, disable filtering - for device in self.dev: - self._device_items[device].setHidden(False) - return - for device in self.dev: - self._device_items[device].setHidden(not self.regex.search(device)) + self.dev_list.update_filter(self.ui.filter_input.text()) @SafeSlot() def _load_from_file(self): diff --git a/bec_widgets/widgets/services/device_browser/device_browser.ui b/bec_widgets/widgets/services/device_browser/device_browser.ui index 9a2d4ce28..0903854c8 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.ui +++ b/bec_widgets/widgets/services/device_browser/device_browser.ui @@ -1,93 +1,90 @@ - Form - - - - 0 - 0 - 406 - 500 - - - - Form - - - - - - Device Browser - - - - - - - - Filter - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - ... - - - - - - - ... - - - - - - - ... - - - - - - - - - - - - + Form + + + + 0 + 0 + 406 + 500 + - - warning + + Form - - - - - - + + + + + Device Browser + + + + + + + + Filter + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + ... + + + + + + + ... + + + + + + + ... + + + + + + + + + + + + + + + warning + + + + + + + - - - - - - + + + \ No newline at end of file diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_item.py b/bec_widgets/widgets/services/device_browser/device_item/device_item.py index def709eb2..f24e2fd24 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_item.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_item.py @@ -35,9 +35,6 @@ class DeviceItem(ExpandableGroupFrame): - broadcast_size_hint = Signal(QSize) - imminent_deletion = Signal() - RPC = False def __init__( diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index 3ef97af8a..2ccb25856 100644 --- a/tests/unit_tests/test_device_browser.py +++ b/tests/unit_tests/test_device_browser.py @@ -37,11 +37,11 @@ def device_browser(qtbot, mocked_client): yield dev_browser -def test_device_browser_init_with_devices(device_browser): +def test_device_browser_init_with_devices(device_browser: DeviceBrowser): """ Test that the device browser is initialized with the correct number of devices. """ - device_list = device_browser.ui.device_list + device_list = device_browser.dev_list assert device_list.count() == len(device_browser.dev) @@ -58,11 +58,11 @@ def test_device_browser_filtering( expected = expected_num_visible if expected_num_visible >= 0 else len(device_browser.dev) def num_visible(item_dict): - return len(list(filter(lambda i: not i.isHidden(), item_dict.values()))) + return len(list(filter(lambda i: not i.widget.isHidden(), item_dict.values()))) device_browser.ui.filter_input.setText(search_term) qtbot.wait(100) - assert num_visible(device_browser._device_items) == expected + assert num_visible(device_browser.dev_list._item_dict) == expected def test_device_item_mouse_press_event(device_browser, qtbot): @@ -70,8 +70,8 @@ def test_device_item_mouse_press_event(device_browser, qtbot): Test that the mousePressEvent is triggered correctly. """ # Simulate a left mouse press event on the device item - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseClick(widget._title, Qt.MouseButton.LeftButton) @@ -88,8 +88,8 @@ def test_device_item_expansion(device_browser, qtbot): Test that the form is displayed when the item is expanded, and that the expansion is triggered by clicking on the expansion button, the title, or the device icon """ - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) tab_widget: QTabWidget = widget._contents.layout().itemAt(0).widget() qtbot.waitUntil(lambda: tab_widget.widget(0) is not None, timeout=100) @@ -115,8 +115,8 @@ def test_device_item_mouse_press_and_move_events_creates_drag(device_browser, qt """ Test that the mousePressEvent is triggered correctly and initiates a drag. """ - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) device_name = widget.device with mock.patch("qtpy.QtGui.QDrag.exec_") as mock_exec: with mock.patch("qtpy.QtGui.QDrag.setMimeData") as mock_set_mimedata: @@ -133,19 +133,19 @@ def test_device_item_double_click_event(device_browser, qtbot): Test that the mouseDoubleClickEvent is triggered correctly. """ # Simulate a left mouse press event on the device item - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) qtbot.mouseDClick(widget, Qt.LeftButton) def test_device_deletion(device_browser, qtbot): - device_item: QListWidgetItem = device_browser.ui.device_list.itemAt(0, 0) - widget: DeviceItem = device_browser.ui.device_list.itemWidget(device_item) + device_item: QListWidgetItem = device_browser.dev_list.itemAt(0, 0) + widget: DeviceItem = device_browser.dev_list.itemWidget(device_item) widget._config_helper = mock.MagicMock() - assert widget.device in device_browser._device_items + assert widget.device in device_browser.dev_list._item_dict qtbot.mouseClick(widget.delete_button, Qt.LeftButton) - qtbot.waitUntil(lambda: widget.device not in device_browser._device_items, timeout=10000) + qtbot.waitUntil(lambda: widget.device not in device_browser.dev_list._item_dict, timeout=10000) def test_signal_display(mocked_client, qtbot): From 03441e93d07288885f839f3322761497a0a3e554 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 11:25:02 +0200 Subject: [PATCH 048/161] feat: add available device resource browser --- .../available_device_resources/__init__.py | 3 + .../available_device_resources/_util.py | 36 ++++ .../available_device_group.py | 200 ++++++++++++++++++ .../available_device_group_ui.py | 66 ++++++ .../available_device_resources.py | 83 ++++++++ .../available_device_resources_ui.py | 55 +++++ .../device_resource_backend.py | 139 ++++++++++++ 7 files changed, 582 insertions(+) create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/_util.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py create mode 100644 bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py new file mode 100644 index 000000000..83d4d4d0f --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/__init__.py @@ -0,0 +1,3 @@ +from .available_device_resources import AvailableDeviceResources + +__all__ = ["AvailableDeviceResources"] diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/_util.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/_util.py new file mode 100644 index 000000000..c848da922 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/_util.py @@ -0,0 +1,36 @@ +from typing import Any, Callable, Generator, Iterable, TypeVar + +from qtpy.QtWidgets import QListWidgetItem + +_T = TypeVar("_T") +_RT = TypeVar("_RT") + + +def yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator[_RT, Any, None]: + for v in vals: + try: + yield fn(v) + except BaseException: + pass + + +SORT_KEY_ROLE = 117 + + +class SortableQListWidgetItem(QListWidgetItem): + """Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with \ + custom widgets and this item.""" + + def __gt__(self, other): + if (self_key := self.data(SORT_KEY_ROLE)) is None or ( + other_key := other.data(SORT_KEY_ROLE) + ) is None: + return False + return self_key.lower() > other_key.lower() + + def __lt__(self, other): + if (self_key := self.data(SORT_KEY_ROLE)) is None or ( + other_key := other.data(SORT_KEY_ROLE) + ) is None: + return False + return self_key.lower() < other_key.lower() diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py new file mode 100644 index 000000000..1fc2810c4 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -0,0 +1,200 @@ +from textwrap import dedent +from typing import NamedTuple + +from bec_qthemes import material_icon +from qtpy.QtCore import QSize +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget + +from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group_ui import ( + Ui_AvailableDeviceGroup, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, +) + + +def _warning_string(spec: HashableDevice): + name_warning = ( + f"Device defined with multiple names! Please check:\n {'\n '.join(spec.names)}\n" + if len(spec.names) > 1 + else "" + ) + source_warning = ( + f"Device found in multiple source files! Please check:\n {'\n '.join(spec._source_files)}" + if len(spec._source_files) > 1 + else "" + ) + return f"{name_warning}{source_warning}" + + +class _DeviceEntryWidget(QFrame): + + def __init__(self, device_spec: HashableDevice, parent=None, **kwargs): + super().__init__(parent, **kwargs) + self._device_spec = device_spec + self.included: bool = False + + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + + self._layout = QVBoxLayout() + self._layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self._layout) + + self.setup_title_layout(device_spec) + self.check_and_display_warning() + + self.setToolTip(self._rich_text()) + + def _rich_text(self): + return dedent( + f""" +

{self._device_spec.name}:

+ + + + + +
description: {self._device_spec.description}
config: {self._device_spec.deviceConfig}
enabled: {self._device_spec.enabled}
read only: {self._device_spec.readOnly}
+ """ + ) + + def setup_title_layout(self, device_spec: HashableDevice): + self._title_layout = QHBoxLayout() + self._title_layout.setContentsMargins(0, 0, 0, 0) + self._title_container = QWidget(parent=self) + self._title_container.setLayout(self._title_layout) + + self._warning_label = QLabel() + self._title_layout.addWidget(self._warning_label) + + self.title = QLabel(device_spec.name) + self.title.setToolTip(device_spec.name) + self.title.setStyleSheet(self.title_style("#FF0000")) + self._title_layout.addWidget(self.title) + + self._title_layout.addStretch(1) + self._layout.addWidget(self._title_container) + + def check_and_display_warning(self): + if len(self._device_spec.names) == 1 and len(self._device_spec._source_files) == 1: + self._warning_label.setText("") + self._warning_label.setToolTip("") + else: + self._warning_label.setPixmap(material_icon("warning", size=(12, 12), color="#FFAA00")) + self._warning_label.setToolTip(_warning_string(self._device_spec)) + + @property + def device_hash(self): + return hash(self._device_spec) + + def title_style(self, color: str) -> str: + return f"QLabel {{ color: {color}; font-weight: bold; font-size: 10pt; }}" + + def setTitle(self, text: str): + self.title.setText(text) + + def set_included(self, included: bool): + self.included = included + self.title.setStyleSheet(self.title_style("#00FF00" if included else "#FF0000")) + + +class _DeviceEntry(NamedTuple): + list_item: QListWidgetItem + widget: _DeviceEntryWidget + + +class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup): + def __init__( + self, parent=None, name: str = "TagGroupTitle", data: set[HashableDevice] = set(), **kwargs + ): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + self.title_text = name # type: ignore + self._devices: dict[str, _DeviceEntry] = {} + for device in data: + self._add_item(device) + self.device_list.sortItems() + self.setMinimumSize(self.device_list.sizeHint()) + self._update_num_included() + + self.add_to_composition_button.clicked.connect(self.test) + + def _add_item(self, device: HashableDevice): + item = QListWidgetItem(self.device_list) + widget = _DeviceEntryWidget(device, self) + item.setSizeHint(QSize(widget.width(), widget.height())) + self.device_list.setItemWidget(item, widget) + self.device_list.addItem(item) + self._devices[device.name] = _DeviceEntry(item, widget) + + def reset_devices_state(self): + for dev in self._devices.values(): + dev.widget.set_included(False) + self._update_num_included() + + def set_item_state(self, /, device_hash: int, included: bool): + for dev in self._devices.values(): + if dev.widget.device_hash == device_hash: + dev.widget.set_included(included) + self._update_num_included() + + def _update_num_included(self): + n_included = sum(int(dev.widget.included) for dev in self._devices.values()) + if n_included == 0: + color = "#FF0000" + elif n_included == len(self._devices): + color = "#00FF00" + else: + color = "#FFAA00" + self.n_included.setText(f"{n_included} / {len(self._devices)}") + self.n_included.setStyleSheet(f"QLabel {{ color: {color}; }}") + + def sizeHint(self) -> QSize: + if not getattr(self, "device_list", None) or not self.expanded: + return super().sizeHint() + return QSize( + max(150, self.device_list.viewport().width()), + self.device_list.sizeHintForRow(0) * self.device_list.count() + 50, + ) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.setMinimumHeight(self.sizeHint().height()) + self.setMaximumHeight(self.sizeHint().height()) + + def get_selection(self) -> set[HashableDevice]: + selection = self.device_list.selectedItems() + widgets = (w.widget for _, w in self._devices.items() if w.list_item in selection) + return set(w._device_spec for w in widgets) + + def test(self, *args): + print(self.get_selection()) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}: {self.title_text}" + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = AvailableDeviceGroup(name="Tag group 1") + for item in [ + HashableDevice( + **{ + "name": f"test_device_{i}", + "deviceClass": "TestDeviceClass", + "readoutPriority": "baseline", + "enabled": True, + } + ) + for i in range(5) + ]: + widget._add_item(item) + widget._update_num_included() + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py new file mode 100644 index 000000000..d2565b4ae --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -0,0 +1,66 @@ +from functools import partial + +from bec_qthemes import material_icon +from PySide6.QtWidgets import QFrame +from qtpy.QtCore import QMetaObject +from qtpy.QtWidgets import QLabel, QListWidget, QToolButton, QVBoxLayout + + +class Ui_AvailableDeviceGroup(object): + def setupUi(self, AvailableDeviceGroup): + if not AvailableDeviceGroup.objectName(): + AvailableDeviceGroup.setObjectName("AvailableDeviceGroup") + AvailableDeviceGroup.setMinimumWidth(150) + self.verticalLayout = QVBoxLayout() + self.verticalLayout.setObjectName("verticalLayout") + AvailableDeviceGroup.set_layout(self.verticalLayout) + + title_layout = AvailableDeviceGroup.get_title_layout() + + self.n_included = QLabel(AvailableDeviceGroup, text="...") + self.n_included.setObjectName("n_included") + title_layout.addWidget(self.n_included) + + self.delete_tag_button = QToolButton(AvailableDeviceGroup) + self.delete_tag_button.setObjectName("delete_tag_button") + title_layout.addWidget(self.delete_tag_button) + + self.remove_from_composition_button = QToolButton(AvailableDeviceGroup) + self.remove_from_composition_button.setObjectName("remove_from_composition_button") + title_layout.addWidget(self.remove_from_composition_button) + + self.add_to_composition_button = QToolButton(AvailableDeviceGroup) + self.add_to_composition_button.setObjectName("add_to_composition_button") + title_layout.addWidget(self.add_to_composition_button) + + self.remove_all_button = QToolButton(AvailableDeviceGroup) + self.remove_all_button.setObjectName("remove_all_from_composition_button") + title_layout.addWidget(self.remove_all_button) + + self.add_all_button = QToolButton(AvailableDeviceGroup) + self.add_all_button.setObjectName("add_all_to_composition_button") + title_layout.addWidget(self.add_all_button) + + self.device_list = QListWidget(AvailableDeviceGroup) + self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + self.device_list.setObjectName("device_list") + self.device_list.setFrameStyle(0) + + self.verticalLayout.addWidget(self.device_list) + + self.set_icons() + + QMetaObject.connectSlotsByName(AvailableDeviceGroup) + + def set_icons(self): + icon = partial(material_icon, size=(15, 15), convert_to_pixmap=False) + self.delete_tag_button.setIcon(icon("delete")) + self.delete_tag_button.setToolTip("Delete tag group") + self.remove_from_composition_button.setIcon(icon("remove")) + self.remove_from_composition_button.setToolTip("Remove selected from composition") + self.add_to_composition_button.setIcon(icon("add")) + self.add_to_composition_button.setToolTip("Add selected to composition") + self.remove_all_button.setIcon(icon("chips")) + self.remove_all_button.setToolTip("Remove all with this tag from composition") + self.add_all_button.setIcon(icon("add_box")) + self.add_all_button.setToolTip("Add all with this tag to composition") diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py new file mode 100644 index 000000000..a4b8fc70f --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -0,0 +1,83 @@ +from random import randint +from typing import Any, Iterable + +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components.available_device_resources._util import ( + yield_only_passing, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import ( + Ui_availableDeviceResources, +) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( + HashableDevice, + get_backend, +) + + +class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources): + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self.setupUi(self) + self._backend = get_backend() + self.grouping_selector.addItem("deviceTags") + self.grouping_selector.addItems(self._backend.allowed_sort_keys) + self._grouping_selection_changed("deviceTags") + self.grouping_selector.currentTextChanged.connect(self._grouping_selection_changed) + self.search_box.textChanged.connect(self.device_groups_list.update_filter) + + def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]): + self.device_groups_list.clear() + for device_group, devices in device_groups.items(): + self._add_device_group(device_group, devices) + if self.grouping_selector.currentText == "deviceTags": + self._add_device_group("Untagged devices", self._backend.untagged_devices) + self.device_groups_list.sortItems() + + def _add_device_group(self, device_group: str, devices: set[HashableDevice]): + self.device_groups_list.add_item( + device_group, self.device_groups_list, device_group, devices, expanded=False + ) + + def _reset_devices_state(self): + for device_group in self.device_groups_list.widgets(): + device_group.reset_devices_state() + + def set_devices_state(self, devices: Iterable[HashableDevice], included: bool): + for device in devices: + for device_group in self.device_groups_list.widgets(): + device_group.set_item_state(hash(device), included) + + def resizeEvent(self, event): + super().resizeEvent(event) + for list_item, device_group_widget in self.device_groups_list.item_widget_pairs(): + list_item.setSizeHint(device_group_widget.sizeHint()) + + @SafeSlot(list) + def update_devices_state(self, config_list: list[dict[str, Any]]): + self.set_devices_state(yield_only_passing(HashableDevice.model_validate, config_list), True) + + @SafeSlot(str) + def _grouping_selection_changed(self, sort_key: str): + self.search_box.setText("") + if sort_key == "deviceTags": + device_groups = self._backend.tag_groups + else: + device_groups = self._backend.group_by_key(sort_key) + self.refresh_full_list(device_groups) + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = AvailableDeviceResources() + widget.set_devices_state( + list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True + ) + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py new file mode 100644 index 000000000..4cfac859c --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -0,0 +1,55 @@ +from qtpy.QtCore import QMetaObject, Qt +from qtpy.QtWidgets import ( + QAbstractItemView, + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListView, + QListWidget, + QVBoxLayout, +) + +from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import ( + AvailableDeviceGroup, +) + + +class Ui_availableDeviceResources(object): + def setupUi(self, availableDeviceResources): + if not availableDeviceResources.objectName(): + availableDeviceResources.setObjectName("availableDeviceResources") + self.verticalLayout = QVBoxLayout(availableDeviceResources) + self.verticalLayout.setObjectName("verticalLayout") + + self.search_layout = QHBoxLayout() + self.verticalLayout.addLayout(self.search_layout) + self.search_layout.addWidget(QLabel("Filter groups: ")) + self.search_box = QLineEdit() + self.search_layout.addWidget(self.search_box) + self.search_layout.addWidget(QLabel("Group by: ")) + self.grouping_selector = QComboBox() + self.search_layout.addWidget(self.grouping_selector) + + self.device_groups_list = ListOfExpandableFrames( + availableDeviceResources, AvailableDeviceGroup + ) + self.device_groups_list.setObjectName("device_groups_list") + self.device_groups_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.device_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.device_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.device_groups_list.setMovement(QListView.Movement.Static) + self.device_groups_list.setSpacing(2) + self.device_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly) + self.device_groups_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectItems) + self.device_groups_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) + self.device_groups_list.setDragEnabled(True) + self.device_groups_list.setAcceptDrops(False) + self.device_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + availableDeviceResources.setMinimumWidth(250) + availableDeviceResources.resize(250, availableDeviceResources.height()) + + self.verticalLayout.addWidget(self.device_groups_list) + + QMetaObject.connectSlotsByName(availableDeviceResources) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py new file mode 100644 index 000000000..46cc2aaf8 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import operator +import os +from enum import Enum, auto +from functools import partial, reduce +from glob import glob +from pathlib import Path +from typing import Protocol + +import bec_lib +from bec_lib.atlas_models import HashableDevice, HashableDeviceSet +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.logger import bec_logger +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path + +logger = bec_logger.logger + +_BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.." + + +def get_backend() -> DeviceResourceBackend: + return _ConfigFileBackend() + + +class HashModel(str, Enum): + DEFAULT = auto() + DEFAULT_DEVICECONFIG = auto() + DEFAULT_EPICS = auto() + + +class DeviceResourceBackend(Protocol): + @property + def tag_groups(self) -> dict[str, set[HashableDevice]]: + """A dictionary of all availble devices separated by tag groups. The same device may + appear more than once (in different groups).""" + ... + + @property + def all_devices(self) -> set[HashableDevice]: + """A set of all availble devices. The same device may not appear more than once.""" + ... + + @property + def untagged_devices(self) -> set[HashableDevice]: + """A set of all untagged devices. The same device may not appear more than once.""" + ... + + @property + def allowed_sort_keys(self) -> set[str]: + """A set of all fields which you may group devices by""" + ... + + def tags(self) -> set[str]: + """Returns a set of all the tags in all available devices.""" + ... + + def tag_group(self, tag: str) -> set[HashableDevice]: + """Returns a set of the devices in the tag group with the given key.""" + ... + + def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]: + """Return a dict of all devices, organised by the specified key, which must be one of + the string keys in the Device model.""" + ... + + +def _devices_from_file(file: str, include_source: bool = True): + data = yaml_load(file, process_includes=False) + return HashableDeviceSet( + HashableDevice.model_validate( + dev | {"name": name, "source_files": {file} if include_source else set()} + ) + for name, dev in data.items() + ) + + +# use the last n recovery files +_N_RECOVERY_FILES = 3 + + +class _ConfigFileBackend(DeviceResourceBackend): + def __init__(self) -> None: + self._raw_device_set: set[ + HashableDevice + ] = self._get_config_from_backup_files() | self._get_configs_from_plugin_files( + Path(plugin_repo_path()) / plugin_package_name() / "device_configs/" + ) + self._device_groups = self._get_tag_groups() + + def _get_config_from_backup_files(self): + dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs" + files = sorted(glob("*.yaml", root_dir=dir)) + last_n_files = files[-_N_RECOVERY_FILES:] + return reduce( + operator.or_, + map( + partial(_devices_from_file, include_source=False), + (str(dir / f) for f in last_n_files), + ), + ) + + def _get_configs_from_plugin_files(self, dir: Path): + files = glob("*.yaml", root_dir=dir, recursive=True) + return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files))) + + def _get_tag_groups(self) -> dict[str, set[HashableDevice]]: + return { + tag: set(filter(lambda dev: tag in dev.deviceTags, self._raw_device_set)) + for tag in self.tags() + } + + @property + def tag_groups(self): + return self._device_groups + + @property + def all_devices(self): + return self._raw_device_set + + @property + def untagged_devices(self): + return {d for d in self._raw_device_set if d.deviceTags == set()} + + @property + def allowed_sort_keys(self) -> set[str]: + return {n for n, info in HashableDevice.model_fields.items() if info.annotation is str} + + def tags(self) -> set[str]: + return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set)) + + def tag_group(self, tag: str) -> set[HashableDevice]: + return self.tag_groups[tag] + + def group_by_key(self, key: str) -> dict[str, set[HashableDevice]]: + if key not in self.allowed_sort_keys: + raise ValueError(f"Cannot group available devices by model key {key}") + group_names: set[str] = {getattr(item, key) for item in self._raw_device_set} + return {g: {d for d in self._raw_device_set if getattr(d, key) == g} for g in group_names} From 7f77b9b9f411762a3ebe6410aaf1f6dd3d398532 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 15:05:06 +0200 Subject: [PATCH 049/161] fix(dm): add constants.py --- .../widgets/control/device_manager/components/constants.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 bec_widgets/widgets/control/device_manager/components/constants.py diff --git a/bec_widgets/widgets/control/device_manager/components/constants.py b/bec_widgets/widgets/control/device_manager/components/constants.py new file mode 100644 index 000000000..a75950abc --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +# Denotes a MIME type for JSON-encoded list of device config dictionaries +MIME_DEVICE_CONFIG: Final[str] = "application/x-bec_device_config" +CONFIG_DATA_ROLE: Final[int] = 118 From 42fc7c85683b39f004aa5cbb0f4c73c2c73646a4 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 16:02:05 +0200 Subject: [PATCH 050/161] feat: add available devices to manager view --- .../examples/device_manager_view/device_manager_view.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index 62ab960a2..1b49a61ea 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -26,6 +26,9 @@ DMOphydTest, DocstringView, ) +from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import ( + AvailableDeviceResources, +) if TYPE_CHECKING: from bec_lib.client import BECClient @@ -80,7 +83,7 @@ def __init__(self, parent=None, *args, **kwargs): self._root_layout.addWidget(self.dock_manager) # Available Resources Widget - self.available_devices = QWidget(self) + self.available_devices = AvailableDeviceResources(self) self.available_devices_dock = QtAds.CDockWidget("Available Devices", self) self.available_devices_dock.setWidget(self.available_devices) From 4ada3085a9723815a83218b6d32be1fc4513a918 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 16:06:03 +0200 Subject: [PATCH 051/161] feat(device_table): prepare table for drop action --- .../components/device_table_view.py | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 423b8f06d..6ad30e4d1 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -3,7 +3,8 @@ from __future__ import annotations import copy -import time +import json +from typing import List from bec_lib.logger import bec_logger from bec_qthemes import material_icon @@ -12,8 +13,9 @@ from bec_widgets.utils.bec_signal_proxy import BECSignalProxy from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors, get_theme_palette +from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components.constants import MIME_DEVICE_CONFIG from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus logger = bec_logger.logger @@ -260,13 +262,18 @@ def flags(self, index): return QtCore.Qt.NoItemFlags key = self.headers[index.column()] + base_flags = super().flags(index) | ( + QtCore.Qt.ItemFlag.ItemIsEnabled + | QtCore.Qt.ItemFlag.ItemIsSelectable + | QtCore.Qt.ItemFlag.ItemIsDropEnabled + ) + if key in ("enabled", "readOnly"): - base_flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable if self._checkable_columns_enabled.get(key, True): - return base_flags | QtCore.Qt.ItemIsUserCheckable + return base_flags | QtCore.Qt.ItemFlag.ItemIsUserCheckable else: return base_flags # disable editing but still visible - return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + return base_flags def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: """ @@ -293,6 +300,25 @@ def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: return True return False + #################################### + ############ Drag and Drop ######### + #################################### + + def mimeTypes(self) -> List[str]: + return [*super().mimeTypes(), MIME_DEVICE_CONFIG] + + def supportedDropActions(self): + return QtCore.Qt.DropAction.CopyAction | QtCore.Qt.DropAction.MoveAction + + def dropMimeData(self, data, action, row, column, parent): + if action not in [QtCore.Qt.DropAction.CopyAction, QtCore.Qt.DropAction.MoveAction]: + return False + if (raw_data := data.data(MIME_DEVICE_CONFIG)) is None: + return False + device_list = json.loads(raw_data.toStdString()) + self.add_device_configs({dev.pop("name"): dev for dev in device_list}) + return True + #################################### ############ Public methods ######## #################################### @@ -411,6 +437,12 @@ def update_validation_status(self, device_name: str, status: int | ValidationSta class BECTableView(QtWidgets.QTableView): """Table View with custom keyPressEvent to delete rows with backspace or delete key""" + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DropOnly) + def keyPressEvent(self, event) -> None: """ Delete selected rows with backspace or delete key @@ -542,6 +574,19 @@ def filterAcceptsRow(self, source_row: int, source_parent) -> bool: return True return False + def flags(self, index): + return super().flags(index) | QtCore.Qt.ItemFlag.ItemIsDropEnabled + + def supportedDropActions(self): + return self.sourceModel().supportedDropActions() + + def mimeTypes(self): + return self.sourceModel().mimeTypes() + + def dropMimeData(self, data, action, row, column, parent): + sp = self.mapToSource(parent) if parent.isValid() else QtCore.QModelIndex() + return self.sourceModel().dropMimeData(data, action, row, column, sp) + class DeviceTableView(BECWidget, QtWidgets.QWidget): """Device Table View for the device manager.""" From 72c86b9d6c68a4b33794c3871a6d2aa21b5f6051 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 16:06:55 +0200 Subject: [PATCH 052/161] feat: prepare available devices for dragging config --- .../available_device_group.py | 12 ++++++-- .../available_device_group_ui.py | 28 ++++++++++++++++--- .../available_device_resources.py | 4 ++- .../available_device_resources_ui.py | 28 +++++++++++++++++-- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py index 1fc2810c4..057509d50 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -12,16 +12,17 @@ from bec_widgets.widgets.control.device_manager.components.available_device_resources.device_resource_backend import ( HashableDevice, ) +from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE def _warning_string(spec: HashableDevice): name_warning = ( - f"Device defined with multiple names! Please check:\n {'\n '.join(spec.names)}\n" + "Device defined with multiple names! Please check:\n " + "\n ".join(spec.names) if len(spec.names) > 1 else "" ) source_warning = ( - f"Device found in multiple source files! Please check:\n {'\n '.join(spec._source_files)}" + "Device found in multiple source files! Please check:\n " + "\n ".join(spec._source_files) if len(spec._source_files) > 1 else "" ) @@ -112,6 +113,7 @@ def __init__( super().__init__(parent=parent, **kwargs) self.setupUi(self) self.title_text = name # type: ignore + self._mime_data = [] self._devices: dict[str, _DeviceEntry] = {} for device in data: self._add_item(device) @@ -123,12 +125,18 @@ def __init__( def _add_item(self, device: HashableDevice): item = QListWidgetItem(self.device_list) + device_dump = device.model_dump(exclude_defaults=True) + item.setData(CONFIG_DATA_ROLE, device_dump) + self._mime_data.append(device_dump) widget = _DeviceEntryWidget(device, self) item.setSizeHint(QSize(widget.width(), widget.height())) self.device_list.setItemWidget(item, widget) self.device_list.addItem(item) self._devices[device.name] = _DeviceEntry(item, widget) + def create_mime_data(self): + return self._mime_data + def reset_devices_state(self): for dev in self._devices.values(): dev.widget.set_included(False) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py index d2565b4ae..88ce0df3c 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -1,10 +1,28 @@ +import json from functools import partial +from bec_lib.utils.json import ExtendedEncoder from bec_qthemes import material_icon -from PySide6.QtWidgets import QFrame -from qtpy.QtCore import QMetaObject +from qtpy.QtCore import QByteArray, QMetaObject, QMimeData, Qt from qtpy.QtWidgets import QLabel, QListWidget, QToolButton, QVBoxLayout +from bec_widgets.widgets.control.device_manager.components.constants import ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + + +class _DeviceListWiget(QListWidget): + def mimeTypes(self): + return [MIME_DEVICE_CONFIG] + + def mimeData(self, items): + mime_obj = QMimeData() + data = [item.data(CONFIG_DATA_ROLE) for item in items] + byte_array = QByteArray(json.dumps(data, cls=ExtendedEncoder).encode("utf-8")) + mime_obj.setData(MIME_DEVICE_CONFIG, byte_array) + return mime_obj + class Ui_AvailableDeviceGroup(object): def setupUi(self, AvailableDeviceGroup): @@ -41,11 +59,13 @@ def setupUi(self, AvailableDeviceGroup): self.add_all_button.setObjectName("add_all_to_composition_button") title_layout.addWidget(self.add_all_button) - self.device_list = QListWidget(AvailableDeviceGroup) + self.device_list = _DeviceListWiget(AvailableDeviceGroup) self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) self.device_list.setObjectName("device_list") self.device_list.setFrameStyle(0) - + self.device_list.setDragEnabled(True) + self.device_list.setAcceptDrops(False) + self.device_list.setDefaultDropAction(Qt.DropAction.CopyAction) self.verticalLayout.addWidget(self.device_list) self.set_icons() diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py index a4b8fc70f..0fd7505de 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -15,6 +15,7 @@ HashableDevice, get_backend, ) +from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources): @@ -37,9 +38,10 @@ def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]): self.device_groups_list.sortItems() def _add_device_group(self, device_group: str, devices: set[HashableDevice]): - self.device_groups_list.add_item( + item, widget = self.device_groups_list.add_item( device_group, self.device_groups_list, device_group, devices, expanded=False ) + item.setData(CONFIG_DATA_ROLE, widget.create_mime_data()) def _reset_devices_state(self): for device_group in self.device_groups_list.widgets(): diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py index 4cfac859c..0b7a56d90 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -1,4 +1,10 @@ -from qtpy.QtCore import QMetaObject, Qt +from __future__ import annotations + +import itertools +import json + +from bec_lib.utils.json import ExtendedEncoder +from qtpy.QtCore import QByteArray, QMetaObject, QMimeData, Qt from qtpy.QtWidgets import ( QAbstractItemView, QComboBox, @@ -14,6 +20,23 @@ from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import ( AvailableDeviceGroup, ) +from bec_widgets.widgets.control.device_manager.components.constants import ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + + +class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]): + + def mimeTypes(self): + return [MIME_DEVICE_CONFIG] + + def mimeData(self, items): + mime_obj = QMimeData() + data = list(itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items)) + byte_array = QByteArray(json.dumps(data, cls=ExtendedEncoder).encode("utf-8")) + mime_obj.setData(MIME_DEVICE_CONFIG, byte_array) + return mime_obj class Ui_availableDeviceResources(object): @@ -32,7 +55,7 @@ def setupUi(self, availableDeviceResources): self.grouping_selector = QComboBox() self.search_layout.addWidget(self.grouping_selector) - self.device_groups_list = ListOfExpandableFrames( + self.device_groups_list = _ListOfDeviceGroups( availableDeviceResources, AvailableDeviceGroup ) self.device_groups_list.setObjectName("device_groups_list") @@ -46,6 +69,7 @@ def setupUi(self, availableDeviceResources): self.device_groups_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) self.device_groups_list.setDragEnabled(True) self.device_groups_list.setAcceptDrops(False) + self.device_groups_list.setDefaultDropAction(Qt.DropAction.CopyAction) self.device_groups_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) availableDeviceResources.setMinimumWidth(250) availableDeviceResources.resize(250, availableDeviceResources.height()) From d467ecc5e0a67f4f14eea05ff54a1e56a1c4ecf0 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 16:31:24 +0200 Subject: [PATCH 053/161] fix: allow setting state with other conformation of config --- .../available_device_resources/available_device_group.py | 2 -- .../available_device_resources/available_device_resources.py | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py index 057509d50..2fb2d3602 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -121,8 +121,6 @@ def __init__( self.setMinimumSize(self.device_list.sizeHint()) self._update_num_included() - self.add_to_composition_button.clicked.connect(self.test) - def _add_item(self, device: HashableDevice): item = QListWidgetItem(self.device_list) device_dump = device.model_dump(exclude_defaults=True) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py index 0fd7505de..d98d9e5a0 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -57,6 +57,10 @@ def resizeEvent(self, event): for list_item, device_group_widget in self.device_groups_list.item_widget_pairs(): list_item.setSizeHint(device_group_widget.sizeHint()) + @SafeSlot(dict) + def update_devices_state_name_outside(self, configs: dict): + self.update_devices_state([{"name": k, **v} for k, v in configs.items()]) + @SafeSlot(list) def update_devices_state(self, config_list: list[dict[str, Any]]): self.set_devices_state(yield_only_passing(HashableDevice.model_validate, config_list), True) From 7676e863b273cf9b4d58fe093ad475259b43682c Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 16:31:42 +0200 Subject: [PATCH 054/161] feat: connect config update to available devices --- .../examples/device_manager_view/device_manager_view.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index 1b49a61ea..add3fe44c 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -153,6 +153,9 @@ def __init__(self, parent=None, *args, **kwargs): self.device_table_view.update_device_validation ) self.device_table_view.device_configs_added.connect(self.ophyd_test_view.add_device_configs) + self.device_table_view.device_configs_added.connect( + self.available_devices.update_devices_state_name_outside + ) self._add_toolbar() From be812322b052bbc626eaf9352ca2844bdd265bc8 Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 18:25:37 +0200 Subject: [PATCH 055/161] feat: add shared selection signal util --- .../utils/list_of_expandable_frames.py | 2 +- .../{available_device_resources => }/_util.py | 10 +++++-- .../available_device_group.py | 27 +++++++++++++++-- .../available_device_resources.py | 29 +++++++++++++++++-- .../available_device_resources_ui.py | 1 - .../device_manager/components/constants.py | 3 ++ 6 files changed, 62 insertions(+), 10 deletions(-) rename bec_widgets/widgets/control/device_manager/components/{available_device_resources => }/_util.py (83%) diff --git a/bec_widgets/utils/list_of_expandable_frames.py b/bec_widgets/utils/list_of_expandable_frames.py index 1eae99a03..7ad85a713 100644 --- a/bec_widgets/utils/list_of_expandable_frames.py +++ b/bec_widgets/utils/list_of_expandable_frames.py @@ -9,7 +9,7 @@ from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.expandable_frame import ExpandableGroupFrame -from bec_widgets.widgets.control.device_manager.components.available_device_resources._util import ( +from bec_widgets.widgets.control.device_manager.components._util import ( SORT_KEY_ROLE, SortableQListWidgetItem, ) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/_util.py b/bec_widgets/widgets/control/device_manager/components/_util.py similarity index 83% rename from bec_widgets/widgets/control/device_manager/components/available_device_resources/_util.py rename to bec_widgets/widgets/control/device_manager/components/_util.py index c848da922..b6cad8d71 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/_util.py +++ b/bec_widgets/widgets/control/device_manager/components/_util.py @@ -1,7 +1,10 @@ from typing import Any, Callable, Generator, Iterable, TypeVar +from PySide6.QtCore import QObject, Signal from qtpy.QtWidgets import QListWidgetItem +from bec_widgets.widgets.control.device_manager.components.constants import SORT_KEY_ROLE + _T = TypeVar("_T") _RT = TypeVar("_RT") @@ -14,9 +17,6 @@ def yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator pass -SORT_KEY_ROLE = 117 - - class SortableQListWidgetItem(QListWidgetItem): """Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with \ custom widgets and this item.""" @@ -34,3 +34,7 @@ def __lt__(self, other): ) is None: return False return self_key.lower() < other_key.lower() + + +class SharedSelectionSignal(QObject): + proc = Signal(str) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py index 2fb2d3602..d25037b48 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -1,11 +1,14 @@ from textwrap import dedent from typing import NamedTuple +from uuid import uuid4 from bec_qthemes import material_icon -from qtpy.QtCore import QSize +from qtpy.QtCore import QItemSelection, QSize from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget +from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.expandable_frame import ExpandableGroupFrame +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group_ui import ( Ui_AvailableDeviceGroup, ) @@ -108,10 +111,21 @@ class _DeviceEntry(NamedTuple): class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup): def __init__( - self, parent=None, name: str = "TagGroupTitle", data: set[HashableDevice] = set(), **kwargs + self, + parent=None, + name: str = "TagGroupTitle", + data: set[HashableDevice] = set(), + shared_selection_signal=SharedSelectionSignal(), + **kwargs, ): super().__init__(parent=parent, **kwargs) self.setupUi(self) + + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + self.device_list.selectionModel().selectionChanged.connect(self._on_selection_changed) + self.title_text = name # type: ignore self._mime_data = [] self._devices: dict[str, _DeviceEntry] = {} @@ -165,6 +179,15 @@ def sizeHint(self) -> QSize: self.device_list.sizeHintForRow(0) * self.device_list.count() + 50, ) + @SafeSlot(QItemSelection, QItemSelection) + def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.device_list.clearSelection() + def resizeEvent(self, event): super().resizeEvent(event) self.setMinimumHeight(self.sizeHint().height()) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py index d98d9e5a0..ed754f81d 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -1,11 +1,14 @@ from random import randint from typing import Any, Iterable +from uuid import uuid4 +from PySide6.QtCore import QItemSelection from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.widgets.control.device_manager.components.available_device_resources._util import ( +from bec_widgets.widgets.control.device_manager.components._util import ( + SharedSelectionSignal, yield_only_passing, ) from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources_ui import ( @@ -19,10 +22,16 @@ class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources): - def __init__(self, parent=None, **kwargs): + def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), **kwargs): super().__init__(parent=parent, **kwargs) self.setupUi(self) self._backend = get_backend() + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + self.device_groups_list.selectionModel().selectionChanged.connect( + self._on_selection_changed + ) self.grouping_selector.addItem("deviceTags") self.grouping_selector.addItems(self._backend.allowed_sort_keys) self._grouping_selection_changed("deviceTags") @@ -39,7 +48,12 @@ def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]): def _add_device_group(self, device_group: str, devices: set[HashableDevice]): item, widget = self.device_groups_list.add_item( - device_group, self.device_groups_list, device_group, devices, expanded=False + device_group, + self.device_groups_list, + device_group, + devices, + shared_selection_signal=self._shared_selection_signal, + expanded=False, ) item.setData(CONFIG_DATA_ROLE, widget.create_mime_data()) @@ -57,6 +71,15 @@ def resizeEvent(self, event): for list_item, device_group_widget in self.device_groups_list.item_widget_pairs(): list_item.setSizeHint(device_group_widget.sizeHint()) + @SafeSlot(QItemSelection, QItemSelection) + def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.device_groups_list.clearSelection() + @SafeSlot(dict) def update_devices_state_name_outside(self, configs: dict): self.update_devices_state([{"name": k, **v} for k, v in configs.items()]) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py index 0b7a56d90..29217f36a 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -27,7 +27,6 @@ class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]): - def mimeTypes(self): return [MIME_DEVICE_CONFIG] diff --git a/bec_widgets/widgets/control/device_manager/components/constants.py b/bec_widgets/widgets/control/device_manager/components/constants.py index a75950abc..b438470e3 100644 --- a/bec_widgets/widgets/control/device_manager/components/constants.py +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -2,4 +2,7 @@ # Denotes a MIME type for JSON-encoded list of device config dictionaries MIME_DEVICE_CONFIG: Final[str] = "application/x-bec_device_config" + +# Custom user roles +SORT_KEY_ROLE: Final[int] = 117 CONFIG_DATA_ROLE: Final[int] = 118 From a998793d00f14aaee79b79fcc8fb1fea29194ece Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 18:26:02 +0200 Subject: [PATCH 056/161] feat(dm): apply shared selection signal util to view --- .../device_manager_view/device_manager_view.py | 11 +++++++++-- .../components/device_table_view.py | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index add3fe44c..c5b3957e0 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -26,6 +26,7 @@ DMOphydTest, DocstringView, ) +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import ( AvailableDeviceResources, ) @@ -75,6 +76,8 @@ class DeviceManagerView(BECWidget, QWidget): def __init__(self, parent=None, *args, **kwargs): super().__init__(parent=parent, client=None, *args, **kwargs) + self._shared_selection = SharedSelectionSignal() + # Top-level layout hosting a toolbar and the dock manager self._root_layout = QVBoxLayout(self) self._root_layout.setContentsMargins(0, 0, 0, 0) @@ -83,12 +86,16 @@ def __init__(self, parent=None, *args, **kwargs): self._root_layout.addWidget(self.dock_manager) # Available Resources Widget - self.available_devices = AvailableDeviceResources(self) + self.available_devices = AvailableDeviceResources( + self, shared_selection_signal=self._shared_selection + ) self.available_devices_dock = QtAds.CDockWidget("Available Devices", self) self.available_devices_dock.setWidget(self.available_devices) # Device Table View widget - self.device_table_view = DeviceTableView(self) + self.device_table_view = DeviceTableView( + self, shared_selection_signal=self._shared_selection + ) self.device_table_view_dock = QtAds.CDockWidget("Device Table", self) self.device_table_view_dock.setWidget(self.device_table_view) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 6ad30e4d1..17f743861 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -5,6 +5,7 @@ import copy import json from typing import List +from uuid import uuid4 from bec_lib.logger import bec_logger from bec_qthemes import material_icon @@ -15,6 +16,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal from bec_widgets.widgets.control.device_manager.components.constants import MIME_DEVICE_CONFIG from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus @@ -598,9 +600,13 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget): RPC = False PLUGIN = False - def __init__(self, parent=None, client=None): + def __init__(self, parent=None, client=None, shared_selection_signal=SharedSelectionSignal()): super().__init__(client=client, parent=parent, theme_update=True) + self._shared_selection_signal = shared_selection_signal + self._shared_selection_uuid = str(uuid4()) + self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) + self.layout = QtWidgets.QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(4) @@ -731,6 +737,11 @@ def _on_table_resized(self, *args): height = self.wrap_delegate.sizeHint(option, index).height() self.table.setRowHeight(row, height) + @SafeSlot(str) + def _handle_shared_selection_signal(self, uuid: str): + if uuid != self._shared_selection_uuid: + self.table.clearSelection() + @SafeSlot(QtCore.QItemSelection, QtCore.QItemSelection) def _on_selection_changed( self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection @@ -742,6 +753,9 @@ def _on_selection_changed( selected (QtCore.QItemSelection): The selected items. deselected (QtCore.QItemSelection): The deselected items. """ + + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + # TODO also hook up logic if a config update is propagated from somewhere! # selected_indexes = selected.indexes() selected_indexes = self.table.selectionModel().selectedIndexes() From a4e02460ad05b9466bf7d1f153d6c744b9d79a7e Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 2 Sep 2025 21:02:51 +0200 Subject: [PATCH 057/161] refactor: util for MimeData --- .../device_manager/components/_util.py | 19 ++++++++++++++++--- .../available_device_group_ui.py | 11 +++-------- .../available_device_resources.py | 8 +++++++- .../available_device_resources_ui.py | 13 +++++-------- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/_util.py b/bec_widgets/widgets/control/device_manager/components/_util.py index b6cad8d71..fb1f69935 100644 --- a/bec_widgets/widgets/control/device_manager/components/_util.py +++ b/bec_widgets/widgets/control/device_manager/components/_util.py @@ -1,9 +1,14 @@ +import json from typing import Any, Callable, Generator, Iterable, TypeVar -from PySide6.QtCore import QObject, Signal +from bec_lib.utils.json import ExtendedEncoder +from qtpy.QtCore import QByteArray, QMimeData, QObject, Signal # type: ignore from qtpy.QtWidgets import QListWidgetItem -from bec_widgets.widgets.control.device_manager.components.constants import SORT_KEY_ROLE +from bec_widgets.widgets.control.device_manager.components.constants import ( + MIME_DEVICE_CONFIG, + SORT_KEY_ROLE, +) _T = TypeVar("_T") _RT = TypeVar("_RT") @@ -17,8 +22,16 @@ def yield_only_passing(fn: Callable[[_T], _RT], vals: Iterable[_T]) -> Generator pass +def mimedata_from_configs(configs: Iterable[dict]) -> QMimeData: + """Takes an iterable of device configs, gives a QMimeData with the configs json-encoded under the type MIME_DEVICE_CONFIG""" + mime_obj = QMimeData() + byte_array = QByteArray(json.dumps(list(configs), cls=ExtendedEncoder).encode("utf-8")) + mime_obj.setData(MIME_DEVICE_CONFIG, byte_array) + return mime_obj + + class SortableQListWidgetItem(QListWidgetItem): - """Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with \ + """Store a sorting string key with .setData(SORT_KEY_ROLE, key) to be able to sort a list with custom widgets and this item.""" def __gt__(self, other): diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py index 88ce0df3c..0dafc4d03 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -1,11 +1,10 @@ -import json from functools import partial -from bec_lib.utils.json import ExtendedEncoder from bec_qthemes import material_icon -from qtpy.QtCore import QByteArray, QMetaObject, QMimeData, Qt +from qtpy.QtCore import QMetaObject, Qt from qtpy.QtWidgets import QLabel, QListWidget, QToolButton, QVBoxLayout +from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs from bec_widgets.widgets.control.device_manager.components.constants import ( CONFIG_DATA_ROLE, MIME_DEVICE_CONFIG, @@ -17,11 +16,7 @@ def mimeTypes(self): return [MIME_DEVICE_CONFIG] def mimeData(self, items): - mime_obj = QMimeData() - data = [item.data(CONFIG_DATA_ROLE) for item in items] - byte_array = QByteArray(json.dumps(data, cls=ExtendedEncoder).encode("utf-8")) - mime_obj.setData(MIME_DEVICE_CONFIG, byte_array) - return mime_obj + return mimedata_from_configs(item.data(CONFIG_DATA_ROLE) for item in items) class Ui_AvailableDeviceGroup(object): diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py index ed754f81d..b8ef20f4f 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -2,7 +2,7 @@ from typing import Any, Iterable from uuid import uuid4 -from PySide6.QtCore import QItemSelection +from qtpy.QtCore import QItemSelection from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_widget import BECWidget @@ -82,10 +82,16 @@ def _handle_shared_selection_signal(self, uuid: str): @SafeSlot(dict) def update_devices_state_name_outside(self, configs: dict): + """Set the display color of individual devices and update the group display + of numbers included. Accepts a dict with the structure {"device_name": config_dict, ...} + as used in server calls.""" self.update_devices_state([{"name": k, **v} for k, v in configs.items()]) @SafeSlot(list) def update_devices_state(self, config_list: list[dict[str, Any]]): + """Set the display color of individual devices and update the group display of numbers + included. Accepts a list of dicts with the complete config as used in + bec_lib.atlas_models.Device.""" self.set_devices_state(yield_only_passing(HashableDevice.model_validate, config_list), True) @SafeSlot(str) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py index 29217f36a..03fa5252d 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -1,10 +1,8 @@ from __future__ import annotations import itertools -import json -from bec_lib.utils.json import ExtendedEncoder -from qtpy.QtCore import QByteArray, QMetaObject, QMimeData, Qt +from qtpy.QtCore import QMetaObject, Qt from qtpy.QtWidgets import ( QAbstractItemView, QComboBox, @@ -17,6 +15,7 @@ ) from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames +from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import ( AvailableDeviceGroup, ) @@ -31,11 +30,9 @@ def mimeTypes(self): return [MIME_DEVICE_CONFIG] def mimeData(self, items): - mime_obj = QMimeData() - data = list(itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items)) - byte_array = QByteArray(json.dumps(data, cls=ExtendedEncoder).encode("utf-8")) - mime_obj.setData(MIME_DEVICE_CONFIG, byte_array) - return mime_obj + return mimedata_from_configs( + itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items) + ) class Ui_availableDeviceResources(object): From f41ea592bbaa269fdeb06c1296c21f14a34395b8 Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 3 Sep 2025 00:08:03 +0200 Subject: [PATCH 058/161] feat: add/remove functionality for device table refactor: use list of configs for general interfaces --- .../device_manager_view.py | 57 ++- .../available_device_resources.py | 27 +- .../components/device_table_view.py | 395 +++++++++--------- .../components/dm_config_view.py | 4 +- .../components/dm_docstring_view.py | 9 +- .../components/dm_ophyd_test.py | 50 +-- 6 files changed, 259 insertions(+), 283 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index c5b3957e0..a82079ec3 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -19,7 +19,6 @@ from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.control.device_manager.components import ( DeviceTableView, DMConfigView, @@ -54,7 +53,9 @@ def apply(): w = [1.0] * n tot_w = float(n) total_px = ( - splitter.width() if splitter.orientation() == Qt.Horizontal else splitter.height() + splitter.width() + if splitter.orientation() == Qt.Orientation.Horizontal + else splitter.height() ) if total_px < 2: QTimer.singleShot(0, apply) @@ -154,15 +155,16 @@ def __init__(self, parent=None, *args, **kwargs): # self.set_default_view([2, 8, 2], [2, 2, 4]) # Connect slots - self.device_table_view.selected_device.connect(self.dm_config_view.on_select_config) - self.device_table_view.selected_device.connect(self.dm_docs_view.on_select_config) + self.device_table_view.selected_devices.connect(self.dm_config_view.on_select_config) + self.device_table_view.selected_devices.connect(self.dm_docs_view.on_select_config) self.ophyd_test_view.device_validated.connect( self.device_table_view.update_device_validation ) - self.device_table_view.device_configs_added.connect(self.ophyd_test_view.add_device_configs) - self.device_table_view.device_configs_added.connect( - self.available_devices.update_devices_state_name_outside - ) + for slot in [ + self.ophyd_test_view.change_device_configs, + self.available_devices.mark_devices_used, + ]: + self.device_table_view.device_configs_changed.connect(slot) self._add_toolbar() @@ -296,10 +298,10 @@ def _load_redis_action(self): self, "Load currently active config", "Do you really want to flush the current config and reload?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if reply == QMessageBox.Yes: + if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None: cfg = {} config_list = self.client.device_manager._get_redis_device_config() for item in config_list: @@ -339,8 +341,8 @@ def _update_redis_action(self): self, "Not implemented yet", "This feature has not been implemented yet, will be coming soon...!!", - QMessageBox.Cancel, - QMessageBox.Cancel, + QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, ) # Table actions @@ -352,10 +354,10 @@ def _reset_composed_view(self): self, "Clear View", "You are about to clear the current composed config view, please confirm...", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if reply == QMessageBox.Yes: + if reply == QMessageBox.StandardButton.Yes: self.device_table_view.clear_device_configs() # TODO Here we would like to implement a custom popup view, that allows to add new devices @@ -370,21 +372,14 @@ def _add_device_action(self): self, "Not implemented yet", "This feature has not been implemented yet, will be coming soon...!!", - QMessageBox.Cancel, - QMessageBox.Cancel, + QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, ) - # TODO fix the device table remove actions. This is currently not working properly... @SafeSlot() def _remove_device_action(self): """Action for the 'remove_device' action to remove a device.""" - reply = QMessageBox.question( - self, - "Not implemented yet", - "This feature has not been implemented yet, will be coming soon...!!", - QMessageBox.Cancel, - QMessageBox.Cancel, - ) + self.device_table_view.remove_selected_rows() # TODO implement proper logic for validation. We should also carefully review how these jobs update the table, and how we can cancel pending validations # in case they are no longer relevant. We might want to 'block' the interactivity on the items for which validation runs with 'connect'! @@ -396,8 +391,8 @@ def _rerun_validation_action(self): self, "Not implemented yet", "This feature has not been implemented yet, will be coming soon...!!", - QMessageBox.Cancel, - QMessageBox.Cancel, + QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, ) ####### Default view has to be done with setting up splitters ######## @@ -411,9 +406,9 @@ def set_default_view(self, horizontal_weights: list, vertical_weights: list): splitters_h = [] splitters_v = [] for splitter in self.findChildren(QSplitter): - if splitter.orientation() == Qt.Horizontal: + if splitter.orientation() == Qt.Orientation.Horizontal: splitters_h.append(splitter) - elif splitter.orientation() == Qt.Vertical: + elif splitter.orientation() == Qt.Orientation.Vertical: splitters_v.append(splitter) def apply_all(): @@ -465,7 +460,7 @@ def _coerce_v(x): def _get_recovery_config_path(self) -> str: """Get the recovery config path from the log_writer config.""" # pylint: disable=protected-access - log_writer_config: BECClient = self.client._service_config.config.get("log_writer", {}) + log_writer_config = self.client._service_config.config.get("log_writer", {}) writer = DeviceConfigWriter(service_config=log_writer_config) return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py index b8ef20f4f..87ed14ce2 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -57,15 +57,6 @@ def _add_device_group(self, device_group: str, devices: set[HashableDevice]): ) item.setData(CONFIG_DATA_ROLE, widget.create_mime_data()) - def _reset_devices_state(self): - for device_group in self.device_groups_list.widgets(): - device_group.reset_devices_state() - - def set_devices_state(self, devices: Iterable[HashableDevice], included: bool): - for device in devices: - for device_group in self.device_groups_list.widgets(): - device_group.set_item_state(hash(device), included) - def resizeEvent(self, event): super().resizeEvent(event) for list_item, device_group_widget in self.device_groups_list.item_widget_pairs(): @@ -80,19 +71,19 @@ def _handle_shared_selection_signal(self, uuid: str): if uuid != self._shared_selection_uuid: self.device_groups_list.clearSelection() - @SafeSlot(dict) - def update_devices_state_name_outside(self, configs: dict): - """Set the display color of individual devices and update the group display - of numbers included. Accepts a dict with the structure {"device_name": config_dict, ...} - as used in server calls.""" - self.update_devices_state([{"name": k, **v} for k, v in configs.items()]) + def _set_devices_state(self, devices: Iterable[HashableDevice], included: bool): + for device in devices: + for device_group in self.device_groups_list.widgets(): + device_group.set_item_state(hash(device), included) @SafeSlot(list) - def update_devices_state(self, config_list: list[dict[str, Any]]): + def mark_devices_used(self, config_list: list[dict[str, Any]], used: bool): """Set the display color of individual devices and update the group display of numbers included. Accepts a list of dicts with the complete config as used in bec_lib.atlas_models.Device.""" - self.set_devices_state(yield_only_passing(HashableDevice.model_validate, config_list), True) + self._set_devices_state( + yield_only_passing(HashableDevice.model_validate, config_list), used + ) @SafeSlot(str) def _grouping_selection_changed(self, sort_key: str): @@ -111,7 +102,7 @@ def _grouping_selection_changed(self, sort_key: str): app = QApplication(sys.argv) widget = AvailableDeviceResources() - widget.set_devices_state( + widget._set_devices_state( list(filter(lambda _: randint(0, 1) == 1, widget._backend.all_devices)), True ) widget.show() diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 17f743861..add789733 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -4,12 +4,15 @@ import copy import json -from typing import List +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Iterable, List from uuid import uuid4 from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer +from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox from thefuzz import fuzz from bec_widgets.utils.bec_signal_proxy import BECSignalProxy @@ -20,8 +23,13 @@ from bec_widgets.widgets.control.device_manager.components.constants import MIME_DEVICE_CONFIG from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus +if TYPE_CHECKING: # pragma: no cover + from bec_qthemes._theme import AccentColors + logger = bec_logger.logger +_DeviceCfgIter = Iterable[dict[str, Any]] + # Threshold for fuzzy matching, careful with adjusting this. 80 seems good FUZZY_SEARCH_THRESHOLD = 80 @@ -31,7 +39,7 @@ class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): def helpEvent(self, event, view, option, index): """Override to show tooltip when hovering.""" - if event.type() != QtCore.QEvent.ToolTip: + if event.type() != QtCore.QEvent.Type.ToolTip: return super().helpEvent(event, view, option, index) model: DeviceFilterProxyModel = index.model() model_index = model.mapToSource(index) @@ -46,7 +54,7 @@ class CenterCheckBoxDelegate(DictToolTipDelegate): def __init__(self, parent=None, colors=None): super().__init__(parent) - self._colors = colors if colors else get_accent_colors() + self._colors: AccentColors = colors if colors else get_accent_colors() # type: ignore self._icon_checked = material_icon( "check_box", size=QtCore.QSize(16, 16), color=self._colors.default, filled=True ) @@ -63,13 +71,13 @@ def apply_theme(self, theme: str | None = None): self._icon_unchecked.setColor(colors.default) def paint(self, painter, option, index): - value = index.model().data(index, QtCore.Qt.CheckStateRole) + value = index.model().data(index, Qt.ItemDataRole.CheckStateRole) if value is None: super().paint(painter, option, index) return # Choose icon based on state - pixmap = self._icon_checked if value == QtCore.Qt.Checked else self._icon_unchecked + pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked # Draw icon centered rect = option.rect @@ -78,11 +86,13 @@ def paint(self, painter, option, index): painter.drawPixmap(pix_rect.topLeft(), pixmap) def editorEvent(self, event, model, option, index): - if event.type() != QtCore.QEvent.MouseButtonRelease: + if event.type() != QtCore.QEvent.Type.MouseButtonRelease: return False - current = model.data(index, QtCore.Qt.CheckStateRole) - new_state = QtCore.Qt.Unchecked if current == QtCore.Qt.Checked else QtCore.Qt.Checked - return model.setData(index, new_state, QtCore.Qt.CheckStateRole) + current = model.data(index, Qt.ItemDataRole.CheckStateRole) + new_state = ( + Qt.CheckState.Unchecked if current == Qt.CheckState.Checked else Qt.CheckState.Checked + ) + return model.setData(index, new_state, Qt.ItemDataRole.CheckStateRole) class DeviceValidatedDelegate(DictToolTipDelegate): @@ -109,7 +119,7 @@ def apply_theme(self, theme: str | None = None): icon.setColor(colors[status]) def paint(self, painter, option, index): - status = index.model().data(index, QtCore.Qt.DisplayRole) + status = index.model().data(index, Qt.ItemDataRole.DisplayRole) if status is None: return super().paint(painter, option, index) @@ -131,25 +141,25 @@ def __init__(self, table: BECTableView, parent=None): self._table = table def paint(self, painter, option, index): - text = index.model().data(index, QtCore.Qt.DisplayRole) + text = index.model().data(index, Qt.ItemDataRole.DisplayRole) if not text: return super().paint(painter, option, index) painter.save() painter.setClipRect(option.rect) - text_option = QtCore.Qt.TextWordWrap | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop + text_option = Qt.TextWordWrap | Qt.AlignLeft | Qt.AlignTop painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text) painter.restore() def sizeHint(self, option, index): - text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "") + text = str(index.model().data(index, Qt.ItemDataRole.DisplayRole) or "") column_width = self._table.columnWidth(index.column()) - 8 # -4 & 4 # Avoid pathological heights for too-narrow columns min_width = option.fontMetrics.averageCharWidth() * 4 if column_width < min_width: fm = QtGui.QFontMetrics(option.font) - elided = fm.elidedText(text, QtCore.Qt.ElideRight, column_width) + elided = fm.elidedText(text, Qt.ElideRight, column_width) return QtCore.QSize(column_width, fm.height() + 4) doc = QtGui.QTextDocument() @@ -160,24 +170,6 @@ def sizeHint(self, option, index): layout_height = doc.documentLayout().documentSize().height() return QtCore.QSize(column_width, int(layout_height) + 4) - # def sizeHint(self, option, index): - # text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "") - # # if not text: - # # return super().sizeHint(option, index) - - # # Use the actual column width - # table = index.model().parent() # or store reference to QTableView - # column_width = table.columnWidth(index.column()) # - 8 - - # doc = QtGui.QTextDocument() - # doc.setDefaultFont(option.font) - # doc.setTextWidth(column_width) - # doc.setPlainText(text) - - # layout_height = doc.documentLayout().documentSize().height() - # height = int(layout_height) + 4 # Needs some extra padding, otherwise it gets cut off - # return QtCore.QSize(column_width, height) - class DeviceTableModel(QtCore.QAbstractTableModel): """ @@ -186,13 +178,12 @@ class DeviceTableModel(QtCore.QAbstractTableModel): Sort logic is implemented directly on the data of the table view. """ - device_configs_added = QtCore.Signal(dict) # Dict[str, dict] of configs that were added - devices_removed = QtCore.Signal(list) # List of strings with device names that were removed + # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed + configs_changed = QtCore.Signal(list, bool) def __init__(self, parent=None): super().__init__(parent) - self._device_config: dict[str, dict] = {} - self._list_items: list[dict] = [] + self._device_config: list[dict[str, Any]] = [] self._validation_status: dict[str, ValidationStatus] = {} self.headers = [ "", @@ -209,14 +200,16 @@ def __init__(self, parent=None): ########## Overwrite custom Qt methods ######## ############################################### - def rowCount(self, parent=QtCore.QModelIndex()) -> int: - return len(self._list_items) + def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex()) -> int: + return len(self._device_config) - def columnCount(self, parent=QtCore.QModelIndex()) -> int: + def columnCount( + self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex() + ) -> int: return len(self.headers) - def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): - if role == QtCore.Qt.DisplayRole and orientation == QtCore.Qt.Horizontal: + def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)): + if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: return self.headers[section] return None @@ -224,22 +217,22 @@ def get_row_data(self, index: QtCore.QModelIndex) -> dict: """Return the row data for the given index.""" if not index.isValid(): return {} - return copy.deepcopy(self._list_items[index.row()]) + return copy.deepcopy(self._device_config[index.row()]) - def data(self, index, role=QtCore.Qt.DisplayRole): + def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)): """Return data for the given index and role.""" if not index.isValid(): return None row, col = index.row(), index.column() - if col == 0 and role == QtCore.Qt.DisplayRole: # QtCore.Qt.DisplayRole: - dev_name = self._list_items[row].get("name", "") + if col == 0 and role == Qt.ItemDataRole.DisplayRole: + dev_name = self._device_config[row].get("name", "") return self._validation_status.get(dev_name, ValidationStatus.PENDING) key = self.headers[col] - value = self._list_items[row].get(key) + value = self._device_config[row].get(key) - if role == QtCore.Qt.DisplayRole: + if role == Qt.ItemDataRole.DisplayRole: if key in ("enabled", "readOnly"): return bool(value) if key == "deviceTags": @@ -247,13 +240,13 @@ def data(self, index, role=QtCore.Qt.DisplayRole): if key == "deviceClass": return str(value).split(".")[-1] return str(value) if value is not None else "" - if role == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"): - return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked - if role == QtCore.Qt.TextAlignmentRole: + if role == Qt.ItemDataRole.CheckStateRole and key in ("enabled", "readOnly"): + return Qt.CheckState.Checked if value else Qt.CheckState.Unchecked + if role == Qt.ItemDataRole.TextAlignmentRole: if key in ("enabled", "readOnly"): - return QtCore.Qt.AlignCenter - return QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - if role == QtCore.Qt.FontRole: + return Qt.AlignmentFlag.AlignCenter + return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + if role == Qt.ItemDataRole.FontRole: font = QtGui.QFont() return font return None @@ -261,23 +254,21 @@ def data(self, index, role=QtCore.Qt.DisplayRole): def flags(self, index): """Flags for the table model.""" if not index.isValid(): - return QtCore.Qt.NoItemFlags + return Qt.ItemFlag.NoItemFlags key = self.headers[index.column()] base_flags = super().flags(index) | ( - QtCore.Qt.ItemFlag.ItemIsEnabled - | QtCore.Qt.ItemFlag.ItemIsSelectable - | QtCore.Qt.ItemFlag.ItemIsDropEnabled + Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled ) if key in ("enabled", "readOnly"): if self._checkable_columns_enabled.get(key, True): - return base_flags | QtCore.Qt.ItemFlag.ItemIsUserCheckable + return base_flags | Qt.ItemFlag.ItemIsUserCheckable else: return base_flags # disable editing but still visible return base_flags - def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: + def setData(self, index, value, role=int(Qt.ItemDataRole.EditRole)) -> bool: """ Method to set the data of the table. @@ -294,11 +285,11 @@ def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: key = self.headers[index.column()] row = index.row() - if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole: + if key in ("enabled", "readOnly") and role == Qt.ItemDataRole.CheckStateRole: if not self._checkable_columns_enabled.get(key, True): return False # ignore changes if column is disabled - self._list_items[row][key] = value == QtCore.Qt.Checked - self.dataChanged.emit(index, index, [QtCore.Qt.CheckStateRole]) + self._device_config[row][key] = value == Qt.CheckState.Checked + self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole]) return True return False @@ -310,100 +301,111 @@ def mimeTypes(self) -> List[str]: return [*super().mimeTypes(), MIME_DEVICE_CONFIG] def supportedDropActions(self): - return QtCore.Qt.DropAction.CopyAction | QtCore.Qt.DropAction.MoveAction + return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction def dropMimeData(self, data, action, row, column, parent): - if action not in [QtCore.Qt.DropAction.CopyAction, QtCore.Qt.DropAction.MoveAction]: + if action not in [Qt.DropAction.CopyAction, Qt.DropAction.MoveAction]: return False if (raw_data := data.data(MIME_DEVICE_CONFIG)) is None: return False - device_list = json.loads(raw_data.toStdString()) - self.add_device_configs({dev.pop("name"): dev for dev in device_list}) + self.add_device_configs(json.loads(raw_data.toStdString())) return True #################################### ############ Public methods ######## #################################### - def get_device_config(self) -> dict[str, dict]: + def get_device_config(self) -> list[dict[str, Any]]: """Method to get the device configuration.""" return self._device_config - def add_device_configs(self, device_configs: dict[str, dict]): + def device_names(self, configs: _DeviceCfgIter | None = None) -> set[str]: + _configs = self._device_config if configs is None else configs + return set(cfg.get("name") for cfg in _configs if cfg.get("name") is not None) # type: ignore + + def _name_exists_in_config(self, name: str, exists: bool): + if (name in self.device_names()) == exists: + return True + return not exists + + def add_device_configs(self, device_configs: _DeviceCfgIter): """ Add devices to the model. Args: - device_configs (dict[str, dict]): A dictionary of device configurations to add. + device_configs (_DeviceCfgList): An iterable of device configurations to add. """ already_in_list = [] - for k, cfg in device_configs.items(): - if k in self._device_config: - logger.warning(f"Device {k} already exists in the model.") - already_in_list.append(k) + added_configs = [] + for cfg in device_configs: + if self._name_exists_in_config(name := cfg.get("name", ""), True): + logger.warning(f"Device {name} already exists in the model.") + already_in_list.append(name) continue - self._device_config[k] = cfg - new_list_cfg = copy.deepcopy(cfg) - new_list_cfg["name"] = k - row = len(self._list_items) + row = len(self._device_config) self.beginInsertRows(QtCore.QModelIndex(), row, row) - self._list_items.append(new_list_cfg) + self._device_config.append(copy.deepcopy(cfg)) + added_configs.append(cfg) self.endInsertRows() - for k in already_in_list: - device_configs.pop(k) - self.device_configs_added.emit(device_configs) + self.configs_changed.emit(device_configs, True) - def set_device_config(self, device_configs: dict[str, dict]): + def remove_device_configs(self, device_configs: _DeviceCfgIter): """ - Replace the device config. + Remove devices from the model. Args: - device_config (dict[str, dict]): The new device config to set. + device_configs (_DeviceCfgList): An iterable of device configurations to remove. """ - diff_names = set(device_configs.keys()) - set(self._device_config.keys()) - self.beginResetModel() - self._device_config.clear() - self._list_items.clear() - for k, cfg in device_configs.items(): - self._device_config[k] = cfg - new_list_cfg = copy.deepcopy(cfg) - new_list_cfg["name"] = k - self._list_items.append(new_list_cfg) - self.endResetModel() - self.devices_removed.emit(diff_names) - self.device_configs_added.emit(device_configs) + removed = [] + for cfg in device_configs: + if cfg not in self._device_config: + logger.warning(f"Device {cfg.get('name')} does not exist in the model.") + continue + with self._remove_row(self._device_config.index(cfg)) as row: + removed.append(self._device_config.pop(row)) + self.configs_changed.emit(removed, False) + + def remove_configs_by_name(self, names: Iterable[str]): + configs = filter(lambda cfg: cfg is not None, (self.get_by_name(name) for name in names)) + self.remove_device_configs(configs) # type: ignore # Nones are filtered + + def get_by_name(self, name: str) -> dict[str, Any] | None: + for cfg in self._device_config: + if cfg.get(name) == name: + return cfg + logger.warning(f"Device {name} does not exist in the model.") + + @contextmanager + def _remove_row(self, row: int): + self.beginRemoveRows(QtCore.QModelIndex(), row, row) + try: + yield row + finally: + self.endRemoveRows() - def remove_device_configs(self, device_configs: dict[str, dict]): + def set_device_config(self, device_configs: _DeviceCfgIter): """ - Remove devices from the model. + Replace the device config. Args: - device_configs (dict[str, dict]): A dictionary of device configurations to remove. + device_config (Iterable[dict[str,Any]]): An iterable of device configurations to set. """ - removed = [] - for k in device_configs.keys(): - if k not in self._device_config: - logger.warning(f"Device {k} does not exist in the model.") - continue - new_cfg = self._device_config.pop(k) - new_cfg["name"] = k - row = self._list_items.index(new_cfg) - self.beginRemoveRows(QtCore.QModelIndex(), row, row) - self._list_items.pop(row) - self.endRemoveRows() - removed.append(k) - self.devices_removed.emit(removed) + diff_names = self.device_names(device_configs) - self.device_names() + diff = [cfg for cfg in self._device_config if cfg.get("name") in diff_names] + self.beginResetModel() + self._device_config = copy.deepcopy(list(device_configs)) + self.endResetModel() + self.configs_changed.emit(diff, False) + self.configs_changed.emit(device_configs, True) def clear_table(self): """ Clear the table. """ - device_names = list(self._device_config.keys()) self.beginResetModel() self._device_config.clear() - self._list_items.clear() self.endResetModel() - self.devices_removed.emit(device_names) + self.configs_changed.emit(self._device_config, False) def update_validation_status(self, device_name: str, status: int | ValidationStatus): """ @@ -415,14 +417,12 @@ def update_validation_status(self, device_name: str, status: int | ValidationSta """ if isinstance(status, int): status = ValidationStatus(status) - if device_name not in self._device_config: - logger.warning( - f"Device {device_name} not found in device_config dict {self._device_config}" - ) + if device_name not in self.device_names(): + logger.warning(f"Device {device_name} not found in table") return self._validation_status[device_name] = status row = None - for ii, item in enumerate(self._list_items): + for ii, item in enumerate(self._device_config): if item["name"] == device_name: row = ii break @@ -433,7 +433,7 @@ def update_validation_status(self, device_name: str, status: int | ValidationSta return # Emit dataChanged for column 0 (status column) index = self.index(row, 0) - self.dataChanged.emit(index, index, [QtCore.Qt.DisplayRole]) + self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole]) class BECTableView(QtWidgets.QTableView): @@ -445,6 +445,9 @@ def __init__(self, *args, **kwargs) -> None: self.setDropIndicatorShown(True) self.setDragDropMode(QtWidgets.QTableView.DragDropMode.DropOnly) + def model(self) -> DeviceFilterProxyModel: + return super().model() # type: ignore + def keyPressEvent(self, event) -> None: """ Delete selected rows with backspace or delete key @@ -452,22 +455,21 @@ def keyPressEvent(self, event) -> None: Args: event: keyPressEvent """ - if event.key() not in (QtCore.Qt.Key_Backspace, QtCore.Qt.Key_Delete): - return super().keyPressEvent(event) + if event.key() in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete): + return self.delete_selected() + return super().keyPressEvent(event) - proxy_indexes = self.selectedIndexes() + def selected_configs(self): + return self.model().get_row_data(self.selectionModel().selectedRows()) + + def delete_selected(self): + proxy_indexes = self.selectionModel().selectedRows() if not proxy_indexes: return - - source_rows = self._get_source_rows(proxy_indexes) - model: DeviceTableModel = self.model().sourceModel() # access underlying model - # Delegate confirmation and removal to helper - removed = self._confirm_and_remove_rows(model, source_rows) - if not removed: - return + self._confirm_and_remove_rows(model, self._get_source_rows(proxy_indexes)) - def _get_source_rows(self, proxy_indexes: list[QtWidgets.QModelIndex]) -> list[int]: + def _get_source_rows(self, proxy_indexes: list[QModelIndex]) -> list[QModelIndex]: """ Map proxy model indices to source model row indices. @@ -478,33 +480,33 @@ def _get_source_rows(self, proxy_indexes: list[QtWidgets.QModelIndex]) -> list[i list[int]: List of source model row indices. """ proxy_rows = sorted({idx for idx in proxy_indexes}, reverse=True) - source_rows = [self.model().mapToSource(idx).row() for idx in proxy_rows] - return list(set(source_rows)) + return list(set(self.model().mapToSource(idx) for idx in proxy_rows)) - def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool: + def _confirm_and_remove_rows( + self, model: DeviceTableModel, source_rows: list[QModelIndex] + ) -> bool: """ Prompt the user to confirm removal of rows and remove them from the model if accepted. Returns True if rows were removed, False otherwise. """ - configs = [model._list_items[r] for r in sorted(source_rows)] + configs = [model.get_row_data(r) for r in sorted(source_rows, key=lambda r: r.row())] names = [cfg.get("name", "") for cfg in configs] - msg = QtWidgets.QMessageBox(self) - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setWindowTitle("Confirm remove devices") - if len(names) == 1: - msg.setText(f"Remove device '{names[0]}'?") - else: - msg.setText(f"Remove {len(names)} devices?") - msg.setInformativeText("\n".join(names)) - msg.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) - msg.setDefaultButton(QtWidgets.QMessageBox.Cancel) + msg = QMessageBox(self) + msg.setIcon(QMessageBox.Icon.Warning) + msg.setWindowTitle("Confirm device removal") + msg.setText( + f"Remove device '{names[0]}'?" if len(names) == 1 else f"Remove {len(names)} devices?" + ) + separator = "\n" if len(names) < 12 else ", " + msg.setInformativeText("Selected devices: \n" + separator.join(names)) + msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel) + msg.setDefaultButton(QMessageBox.StandardButton.Cancel) res = msg.exec_() - if res == QtWidgets.QMessageBox.Ok: - configs_to_be_removed = {model._device_config[name] for name in names} - model.remove_device_configs(configs_to_be_removed) + if res == QMessageBox.StandardButton.Ok: + model.remove_device_configs(configs) return True return False @@ -518,6 +520,12 @@ def __init__(self, parent=None): self._enable_fuzzy = True self._filter_columns = [1, 2] # name and deviceClass for search + def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[dict[str, Any]]: + return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows) + + def sourceModel(self) -> DeviceTableModel: + return super().sourceModel() # type: ignore + def hide_rows(self, row_indices: list[int]): """ Hide specific rows in the model. @@ -566,7 +574,7 @@ def filterAcceptsRow(self, source_row: int, source_parent) -> bool: text = self._filter_text.lower() for column in self._filter_columns: index = model.index(source_row, column, source_parent) - data = str(model.data(index, QtCore.Qt.DisplayRole) or "") + data = str(model.data(index, Qt.ItemDataRole.DisplayRole) or "") if self._enable_fuzzy is True: match_ratio = fuzz.partial_ratio(self._filter_text.lower(), data.lower()) if match_ratio >= FUZZY_SEARCH_THRESHOLD: @@ -577,7 +585,7 @@ def filterAcceptsRow(self, source_row: int, source_parent) -> bool: return False def flags(self, index): - return super().flags(index) | QtCore.Qt.ItemFlag.ItemIsDropEnabled + return super().flags(index) | Qt.ItemFlag.ItemIsDropEnabled def supportedDropActions(self): return self.sourceModel().supportedDropActions() @@ -593,9 +601,10 @@ def dropMimeData(self, data, action, row, column, parent): class DeviceTableView(BECWidget, QtWidgets.QWidget): """Device Table View for the device manager.""" - selected_device = QtCore.Signal(dict) # Selected device configuration dict[str,dict] - device_configs_added = QtCore.Signal(dict) # Dict[str, dict] of configs that were added - devices_removed = QtCore.Signal(list) # List of strings with device names that were removed + # Selected device configuration list[dict[str, Any]] + selected_devices = QtCore.Signal(list) # type: ignore + # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed + device_configs_changed = QtCore.Signal(list, bool) # type: ignore RPC = False PLUGIN = False @@ -607,21 +616,21 @@ def __init__(self, parent=None, client=None, shared_selection_signal=SharedSelec self._shared_selection_uuid = str(uuid4()) self._shared_selection_signal.proc.connect(self._handle_shared_selection_signal) - self.layout = QtWidgets.QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.setSpacing(4) + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self.setLayout(self._layout) # Setup table view self._setup_table_view() # Setup search view, needs table proxy to be iniditate self._setup_search() # Add widgets to main layout - self.layout.addLayout(self.search_controls) - self.layout.addWidget(self.table) + self._layout.addLayout(self.search_controls) + self._layout.addWidget(self.table) # Connect signals - self._model.devices_removed.connect(self.devices_removed.emit) - self._model.device_configs_added.connect(self.device_configs_added.emit) + self._model.configs_changed.connect(self.device_configs_changed.emit) def _setup_search(self): """Create components related to the search functionality""" @@ -657,7 +666,7 @@ def _setup_search(self): self.search_controls.addLayout(self.search_layout) self.search_controls.addSpacing(20) # Add some space between the search box and toggle self.search_controls.addLayout(self.fuzzy_layout) - QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) + QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0)) def _setup_table_view(self) -> None: """Setup the table view.""" @@ -685,13 +694,13 @@ def _setup_table_view(self) -> None: # Column resize policies header = self.table.horizontalHeader() - header.setSectionResizeMode(0, QtWidgets.QHeaderView.Fixed) # ValidationStatus - header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) # name - header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) # deviceClass - header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeToContents) # readoutPriority - header.setSectionResizeMode(4, QtWidgets.QHeaderView.Stretch) # deviceTags - header.setSectionResizeMode(5, QtWidgets.QHeaderView.Fixed) # enabled - header.setSectionResizeMode(6, QtWidgets.QHeaderView.Fixed) # readOnly + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # ValidationStatus + header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # name + header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # deviceClass + header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # readoutPriority + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # deviceTags + header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # enabled + header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # readOnly self.table.setColumnWidth(0, 25) self.table.setColumnWidth(5, 70) @@ -707,15 +716,18 @@ def _setup_table_view(self) -> None: ) # Selection behavior - self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.table.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Connect to selection model to get selection changes self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) self.table.horizontalHeader().setHighlightSections(False) - # QtCore.QTimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0)) + # Qtimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0)) - def get_device_config(self) -> dict[str, dict]: + def remove_selected_rows(self): + self.table.delete_selected() + + def get_device_config(self) -> list[dict[str, Any]]: """Get the device config.""" return self._model.get_device_config() @@ -753,33 +765,22 @@ def _on_selection_changed( selected (QtCore.QItemSelection): The selected items. deselected (QtCore.QItemSelection): The deselected items. """ - self._shared_selection_signal.proc.emit(self._shared_selection_uuid) - - # TODO also hook up logic if a config update is propagated from somewhere! - # selected_indexes = selected.indexes() - selected_indexes = self.table.selectionModel().selectedIndexes() - if not selected_indexes: + if not (selected_configs := list(self.table.selected_configs())): return - - source_indexes = [self.proxy.mapToSource(idx) for idx in selected_indexes] - source_rows = {idx.row() for idx in source_indexes} - configs = [copy.deepcopy(self._model._list_items[r]) for r in sorted(source_rows)] - names = [cfg.pop("name") for cfg in configs] - selected_cfgs = {name: cfg for name, cfg in zip(names, configs)} - self.selected_device.emit(selected_cfgs) + self.selected_devices.emit(selected_configs) ###################################### ##### Ext. Slot API ################# ###################################### - @SafeSlot(dict) - def set_device_config(self, device_configs: dict[str, dict]): + @SafeSlot(list) + def set_device_config(self, device_configs: _DeviceCfgIter): """ Set the device config. Args: - config (dict[str,dict]): The device config to set. + config (Iterable[str,dict]): The device config to set. """ self._model.set_device_config(device_configs) @@ -788,8 +789,8 @@ def clear_device_configs(self): """Clear the device configs.""" self._model.clear_table() - @SafeSlot(dict) - def add_device_configs(self, device_configs: dict[str, dict]): + @SafeSlot(list) + def add_device_configs(self, device_configs: _DeviceCfgIter): """ Add devices to the config. @@ -798,8 +799,8 @@ def add_device_configs(self, device_configs: dict[str, dict]): """ self._model.add_device_configs(device_configs) - @SafeSlot(dict) - def remove_device_configs(self, device_configs: dict[str, dict]): + @SafeSlot(list) + def remove_device_configs(self, device_configs: _DeviceCfgIter): """ Remove devices from the config. @@ -816,11 +817,7 @@ def remove_device(self, device_name: str): Args: device_name (str): The name of the device to remove. """ - cfg = self._model._device_config.get(device_name, None) - if cfg is None: - logger.warning(f"Device {device_name} not found in device_config dict") - return - self._model.remove_device_configs({device_name: cfg}) + self._model.remove_configs_by_name([device_name]) @SafeSlot(str, int) def update_device_validation( @@ -853,7 +850,7 @@ def update_device_validation( layout.addWidget(button) def _button_clicked(): - names = list(window._model._device_config.keys()) + names = list(window._model.device_names()) for name in names: window.update_device_validation( name, ValidationStatus.VALID if np.random.rand() > 0.5 else ValidationStatus.FAILED diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py index 846c84ee4..f13f9a760 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -52,14 +52,14 @@ def _customize_overlay(self): ) @SafeSlot(dict) - def on_select_config(self, device: dict): + def on_select_config(self, device: list[dict]): """Handle selection of a device from the device table.""" if len(device) != 1: text = "" self.stacked_layout.setCurrentWidget(self._overlay_widget) else: try: - text = yaml.dump(device, default_flow_style=False) + text = yaml.dump(device[0], default_flow_style=False) self.stacked_layout.setCurrentWidget(self.monaco_editor) except Exception: content = traceback.format_exc() diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py index 7b128992b..a0f135b43 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -30,7 +30,7 @@ class DocstringView(QtWidgets.QTextEdit): def __init__(self, parent: QtWidgets.QWidget | None = None): super().__init__(parent) self.setReadOnly(True) - self.setFocusPolicy(QtCore.Qt.NoFocus) + self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) if not READY_TO_VIEW: self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.") self.setEnabled(False) @@ -92,13 +92,12 @@ def _set_text(self, text: str): # self.setHtml(self._format_docstring(text)) self.setReadOnly(True) - @SafeSlot(dict) - def on_select_config(self, device: dict): + @SafeSlot(list) + def on_select_config(self, device: list[dict]): if len(device) != 1: self._set_text("") return - k = next(iter(device)) - device_class = device[k].get("deviceClass", "") + device_class = device[0].get("deviceClass", "") self.set_device_class(device_class) @SafeSlot(str) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py index ef9e9fecf..6106dde89 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -6,7 +6,7 @@ import re import traceback from html import escape -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import bec_lib from bec_lib.logger import bec_logger @@ -212,38 +212,32 @@ def _setup_textbox_ui(self): self.splitter.addWidget(self._text_box) @SafeSlot(dict) - def add_device_configs(self, device_configs: dict[str, dict]) -> None: + def change_device_configs(self, device_configs: list[dict[str, Any]], added: bool) -> None: """Receive an update with device configs. Args: - device_configs (dict[str, dict]): The updated device configurations. + device_configs (list[dict[str, Any]]): The updated device configurations. """ - for device_name, device_config in device_configs.items(): - if device_name in self._device_list_items: - logger.error(f"Device {device_name} is already in the list.") + for cfg in device_configs: + name = cfg.get("name", "") + if added: + if name in self._device_list_items: + return + return self._add_device(name, cfg) + if name not in self._device_list_items: return - item = QtWidgets.QListWidgetItem(self._list_widget) - widget = ValidationListItem(device_name=device_name, device_config=device_config) - - # wrap it in a QListWidgetItem - item.setSizeHint(widget.sizeHint()) - self._list_widget.addItem(item) - self._list_widget.setItemWidget(item, widget) - self._device_list_items[device_name] = item - self._run_device_validation(widget) - - @SafeSlot(dict) - def remove_device_configs(self, device_configs: dict[str, dict]) -> None: - """Remove device configs from the list. - - Args: - device_name (str): The name of the device to remove. - """ - for device_name in device_configs.keys(): - if device_name not in self._device_list_items: - logger.warning(f"Device {device_name} not found in list.") - return - self._remove_list_item(device_name) + self._remove_list_item(name) + + def _add_device(self, name, cfg): + item = QtWidgets.QListWidgetItem(self._list_widget) + widget = ValidationListItem(device_name=name, device_config=cfg) + + # wrap it in a QListWidgetItem + item.setSizeHint(widget.sizeHint()) + self._list_widget.addItem(item) + self._list_widget.setItemWidget(item, widget) + self._device_list_items[name] = item + self._run_device_validation(widget) def _remove_list_item(self, device_name: str): """Remove a device from the list.""" From 24c213a705ca5d5db1dc742524d46d7c8925bcdb Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 4 Sep 2025 09:36:19 +0200 Subject: [PATCH 059/161] feat: connect available devices to doc and yaml views --- .../device_manager_view.py | 54 +++++++------------ .../available_device_group.py | 10 ++-- .../available_device_group_ui.py | 7 +++ .../available_device_resources.py | 8 ++- .../available_device_resources_ui.py | 9 ++++ .../components/device_table_view.py | 6 ++- 6 files changed, 53 insertions(+), 41 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index a82079ec3..bf2fbd367 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -30,9 +30,6 @@ AvailableDeviceResources, ) -if TYPE_CHECKING: - from bec_lib.client import BECClient - logger = bec_logger.logger @@ -157,6 +154,8 @@ def __init__(self, parent=None, *args, **kwargs): # Connect slots self.device_table_view.selected_devices.connect(self.dm_config_view.on_select_config) self.device_table_view.selected_devices.connect(self.dm_docs_view.on_select_config) + self.available_devices.selected_devices.connect(self.dm_config_view.on_select_config) + self.available_devices.selected_devices.connect(self.dm_docs_view.on_select_config) self.ophyd_test_view.device_validated.connect( self.device_table_view.update_device_validation ) @@ -260,6 +259,15 @@ def _add_table_actions(self) -> None: # IO actions + def _coming_soon(self): + return QMessageBox.question( + self, + "Not implemented yet", + "This feature has not been implemented yet, will be coming soon...!!", + QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + @SafeSlot() def _load_file_action(self): """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" @@ -282,7 +290,7 @@ def _load_file_action(self): ) if file_path: try: - config = yaml_load(file_path) + config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] except Exception as e: logger.error(f"Failed to load config from file {file_path}. Error: {e}") return @@ -297,18 +305,14 @@ def _load_redis_action(self): reply = QMessageBox.question( self, "Load currently active config", - "Do you really want to flush the current config and reload?", + "Do you really want to discard the current config and reload?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None: - cfg = {} - config_list = self.client.device_manager._get_redis_device_config() - for item in config_list: - k = item["name"] - item.pop("name") - cfg[k] = item - self.device_table_view.set_device_config(cfg) + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) else: return @@ -328,7 +332,7 @@ def _safe_to_disk_action(self): self, caption="Save Config File", dir=config_path ) if file_path: - config = self.device_table_view.get_device_config() + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} with open(file_path, "w") as file: file.write(yaml.dump(config)) @@ -337,13 +341,7 @@ def _safe_to_disk_action(self): def _update_redis_action(self): """Action for the 'update_redis' action to update the current config in Redis.""" config = self.device_table_view.get_device_config() - reply = QMessageBox.question( - self, - "Not implemented yet", - "This feature has not been implemented yet, will be coming soon...!!", - QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Cancel, - ) + reply = self._coming_soon() # Table actions @@ -368,13 +366,7 @@ def _reset_composed_view(self): def _add_device_action(self): """Action for the 'add_device' action to add a new device.""" # Implement the logic to add a new device - reply = QMessageBox.question( - self, - "Not implemented yet", - "This feature has not been implemented yet, will be coming soon...!!", - QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Cancel, - ) + reply = self._coming_soon() @SafeSlot() def _remove_device_action(self): @@ -387,13 +379,7 @@ def _remove_device_action(self): def _rerun_validation_action(self): """Action for the 'rerun_validation' action to rerun validation on selected devices.""" # Implement the logic to rerun validation on selected devices - reply = QMessageBox.question( - self, - "Not implemented yet", - "This feature has not been implemented yet, will be coming soon...!!", - QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Cancel, - ) + reply = self._coming_soon() ####### Default view has to be done with setting up splitters ######## def set_default_view(self, horizontal_weights: list, vertical_weights: list): diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py index d25037b48..9b93304b6 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -3,7 +3,7 @@ from uuid import uuid4 from bec_qthemes import material_icon -from qtpy.QtCore import QItemSelection, QSize +from qtpy.QtCore import QItemSelection, QSize, Signal from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QListWidgetItem, QVBoxLayout, QWidget from bec_widgets.utils.error_popups import SafeSlot @@ -110,6 +110,9 @@ class _DeviceEntry(NamedTuple): class AvailableDeviceGroup(ExpandableGroupFrame, Ui_AvailableDeviceGroup): + + selected_devices = Signal(list) + def __init__( self, parent=None, @@ -182,6 +185,8 @@ def sizeHint(self) -> QSize: @SafeSlot(QItemSelection, QItemSelection) def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + config = [dev.as_normal_device().model_dump() for dev in self.get_selection()] + self.selected_devices.emit(config) @SafeSlot(str) def _handle_shared_selection_signal(self, uuid: str): @@ -198,9 +203,6 @@ def get_selection(self) -> set[HashableDevice]: widgets = (w.widget for _, w in self._devices.items() if w.list_item in selection) return set(w._device_spec for w in widgets) - def test(self, *args): - print(self.get_selection()) - def __repr__(self) -> str: return f"{self.__class__.__name__}: {self.title_text}" diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py index 0dafc4d03..c45f1131f 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -12,6 +12,13 @@ class _DeviceListWiget(QListWidget): + + def _item_iter(self): + return (self.item(i) for i in range(self.count())) + + def all_configs(self): + return [item.data(CONFIG_DATA_ROLE) for item in self._item_iter()] + def mimeTypes(self): return [MIME_DEVICE_CONFIG] diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py index 87ed14ce2..f6bf66167 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -2,7 +2,7 @@ from typing import Any, Iterable from uuid import uuid4 -from qtpy.QtCore import QItemSelection +from qtpy.QtCore import QItemSelection, Signal from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_widget import BECWidget @@ -22,6 +22,9 @@ class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources): + + selected_devices = Signal(list) # list[dict[str,Any]] of device configs currently selected + def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), **kwargs): super().__init__(parent=parent, **kwargs) self.setupUi(self) @@ -56,6 +59,8 @@ def _add_device_group(self, device_group: str, devices: set[HashableDevice]): expanded=False, ) item.setData(CONFIG_DATA_ROLE, widget.create_mime_data()) + # Re-emit the selected items from a subgroup - all other selections should be disabled anyway + widget.selected_devices.connect(self.selected_devices) def resizeEvent(self, event): super().resizeEvent(event) @@ -64,6 +69,7 @@ def resizeEvent(self, event): @SafeSlot(QItemSelection, QItemSelection) def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: + self.selected_devices.emit(self.device_groups_list.selected_devices()) self._shared_selection_signal.proc.emit(self._shared_selection_uuid) @SafeSlot(str) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py index 03fa5252d..3b9ebb22f 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -1,6 +1,7 @@ from __future__ import annotations import itertools +from typing import Generator from qtpy.QtCore import QMetaObject, Qt from qtpy.QtWidgets import ( @@ -26,6 +27,14 @@ class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]): + + def selected_devices(self): + selected_items = (self.item(r.row()) for r in self.selectionModel().selectedRows()) + widgets: Generator[AvailableDeviceGroup, None, None] = ( + self.itemWidget(item) for item in selected_items # type: ignore + ) + return list(itertools.chain.from_iterable(w.device_list.all_configs() for w in widgets)) + def mimeTypes(self): return [MIME_DEVICE_CONFIG] diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index add789733..2b87707b8 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -147,8 +147,10 @@ def paint(self, painter, option, index): painter.save() painter.setClipRect(option.rect) - text_option = Qt.TextWordWrap | Qt.AlignLeft | Qt.AlignTop - painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text) + text_option = ( + Qt.TextFlag.TextWordWrap | Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop + ) + painter.drawText(option.rect.adjusted(4, 2, -5, -2), text_option, text) painter.restore() def sizeHint(self, option, index): From 5281d380cbde98122079cbe27fe664dc5a287c95 Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 4 Sep 2025 13:27:20 +0200 Subject: [PATCH 060/161] fix: add all devices to test list --- .../control/device_manager/components/dm_ophyd_test.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py index 6106dde89..71ef1da53 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -222,10 +222,11 @@ def change_device_configs(self, device_configs: list[dict[str, Any]], added: boo name = cfg.get("name", "") if added: if name in self._device_list_items: - return - return self._add_device(name, cfg) + continue + self._add_device(name, cfg) + continue if name not in self._device_list_items: - return + continue self._remove_list_item(name) def _add_device(self, name, cfg): From cac10668614d8586eaabb5f2450d5c51d04784da Mon Sep 17 00:00:00 2001 From: David Perl Date: Thu, 4 Sep 2025 15:55:07 +0200 Subject: [PATCH 061/161] refactor: available devices add+remove from toolbar --- .../device_manager_view.py | 41 ++++++++++++----- .../available_device_group_ui.py | 40 +--------------- .../available_device_resources.py | 17 ++++++- .../available_device_resources_ui.py | 46 +++++++++++++++++-- 4 files changed, 88 insertions(+), 56 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index bf2fbd367..7ff81c0ff 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -152,18 +152,37 @@ def __init__(self, parent=None, *args, **kwargs): # self.set_default_view([2, 8, 2], [2, 2, 4]) # Connect slots - self.device_table_view.selected_devices.connect(self.dm_config_view.on_select_config) - self.device_table_view.selected_devices.connect(self.dm_docs_view.on_select_config) - self.available_devices.selected_devices.connect(self.dm_config_view.on_select_config) - self.available_devices.selected_devices.connect(self.dm_docs_view.on_select_config) - self.ophyd_test_view.device_validated.connect( - self.device_table_view.update_device_validation - ) - for slot in [ - self.ophyd_test_view.change_device_configs, - self.available_devices.mark_devices_used, + for signal, slots in [ + ( + self.device_table_view.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.available_devices.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.ophyd_test_view.device_validated, + (self.device_table_view.update_device_validation,), + ), + ( + self.device_table_view.device_configs_changed, + ( + self.ophyd_test_view.change_device_configs, + self.available_devices.mark_devices_used, + ), + ), + ( + self.available_devices.add_selected_devices, + (self.device_table_view.add_device_configs,), + ), + ( + self.available_devices.del_selected_devices, + (self.device_table_view.remove_device_configs,), + ), ]: - self.device_table_view.device_configs_changed.connect(slot) + for slot in slots: + signal.connect(slot) self._add_toolbar() diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py index c45f1131f..f4bbf7844 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -1,8 +1,5 @@ -from functools import partial - -from bec_qthemes import material_icon from qtpy.QtCore import QMetaObject, Qt -from qtpy.QtWidgets import QLabel, QListWidget, QToolButton, QVBoxLayout +from qtpy.QtWidgets import QLabel, QListWidget, QVBoxLayout from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs from bec_widgets.widgets.control.device_manager.components.constants import ( @@ -41,26 +38,6 @@ def setupUi(self, AvailableDeviceGroup): self.n_included.setObjectName("n_included") title_layout.addWidget(self.n_included) - self.delete_tag_button = QToolButton(AvailableDeviceGroup) - self.delete_tag_button.setObjectName("delete_tag_button") - title_layout.addWidget(self.delete_tag_button) - - self.remove_from_composition_button = QToolButton(AvailableDeviceGroup) - self.remove_from_composition_button.setObjectName("remove_from_composition_button") - title_layout.addWidget(self.remove_from_composition_button) - - self.add_to_composition_button = QToolButton(AvailableDeviceGroup) - self.add_to_composition_button.setObjectName("add_to_composition_button") - title_layout.addWidget(self.add_to_composition_button) - - self.remove_all_button = QToolButton(AvailableDeviceGroup) - self.remove_all_button.setObjectName("remove_all_from_composition_button") - title_layout.addWidget(self.remove_all_button) - - self.add_all_button = QToolButton(AvailableDeviceGroup) - self.add_all_button.setObjectName("add_all_to_composition_button") - title_layout.addWidget(self.add_all_button) - self.device_list = _DeviceListWiget(AvailableDeviceGroup) self.device_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) self.device_list.setObjectName("device_list") @@ -70,19 +47,4 @@ def setupUi(self, AvailableDeviceGroup): self.device_list.setDefaultDropAction(Qt.DropAction.CopyAction) self.verticalLayout.addWidget(self.device_list) - self.set_icons() - QMetaObject.connectSlotsByName(AvailableDeviceGroup) - - def set_icons(self): - icon = partial(material_icon, size=(15, 15), convert_to_pixmap=False) - self.delete_tag_button.setIcon(icon("delete")) - self.delete_tag_button.setToolTip("Delete tag group") - self.remove_from_composition_button.setIcon(icon("remove")) - self.remove_from_composition_button.setToolTip("Remove selected from composition") - self.add_to_composition_button.setIcon(icon("add")) - self.add_to_composition_button.setToolTip("Add selected to composition") - self.remove_all_button.setIcon(icon("chips")) - self.remove_all_button.setToolTip("Remove all with this tag from composition") - self.add_all_button.setIcon(icon("add_box")) - self.add_all_button.setToolTip("Add all with this tag to composition") diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py index f6bf66167..93e810156 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -2,7 +2,7 @@ from typing import Any, Iterable from uuid import uuid4 -from qtpy.QtCore import QItemSelection, Signal +from qtpy.QtCore import QItemSelection, Signal # type: ignore from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_widget import BECWidget @@ -24,6 +24,8 @@ class AvailableDeviceResources(BECWidget, QWidget, Ui_availableDeviceResources): selected_devices = Signal(list) # list[dict[str,Any]] of device configs currently selected + add_selected_devices = Signal(list) + del_selected_devices = Signal(list) def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), **kwargs): super().__init__(parent=parent, **kwargs) @@ -41,6 +43,9 @@ def __init__(self, parent=None, shared_selection_signal=SharedSelectionSignal(), self.grouping_selector.currentTextChanged.connect(self._grouping_selection_changed) self.search_box.textChanged.connect(self.device_groups_list.update_filter) + self.tb_add_selected.action.triggered.connect(self._add_selected_action) + self.tb_del_selected.action.triggered.connect(self._del_selected_action) + def refresh_full_list(self, device_groups: dict[str, set[HashableDevice]]): self.device_groups_list.clear() for device_group, devices in device_groups.items(): @@ -67,9 +72,17 @@ def resizeEvent(self, event): for list_item, device_group_widget in self.device_groups_list.item_widget_pairs(): list_item.setSizeHint(device_group_widget.sizeHint()) + @SafeSlot() + def _add_selected_action(self): + self.add_selected_devices.emit(self.device_groups_list.any_selected_devices()) + + @SafeSlot() + def _del_selected_action(self): + self.del_selected_devices.emit(self.device_groups_list.any_selected_devices()) + @SafeSlot(QItemSelection, QItemSelection) def _on_selection_changed(self, selected: QItemSelection, deselected: QItemSelection) -> None: - self.selected_devices.emit(self.device_groups_list.selected_devices()) + self.selected_devices.emit(self.device_groups_list.selected_devices_from_groups()) self._shared_selection_signal.proc.emit(self._shared_selection_uuid) @SafeSlot(str) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py index 3b9ebb22f..4cba0bf5a 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -1,8 +1,11 @@ from __future__ import annotations import itertools +from functools import partial from typing import Generator +from bec_qthemes import material_icon +from PySide6.QtWidgets import QListWidgetItem, QWidget from qtpy.QtCore import QMetaObject, Qt from qtpy.QtWidgets import ( QAbstractItemView, @@ -16,6 +19,9 @@ ) from bec_widgets.utils.list_of_expandable_frames import ListOfExpandableFrames +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_group import ( AvailableDeviceGroup, @@ -28,11 +34,21 @@ class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]): - def selected_devices(self): + def itemWidget(self, item: QListWidgetItem) -> AvailableDeviceGroup: + return super().itemWidget(item) # type: ignore + + def any_selected_devices(self): + return self.selected_individual_devices() or self.selected_devices_from_groups() + + def selected_individual_devices(self): + for widget in (self.itemWidget(self.item(i)) for i in range(self.count())): + if (selected := widget.get_selection()) != set(): + return [dev.as_normal_device().model_dump() for dev in selected] + return [] + + def selected_devices_from_groups(self): selected_items = (self.item(r.row()) for r in self.selectionModel().selectedRows()) - widgets: Generator[AvailableDeviceGroup, None, None] = ( - self.itemWidget(item) for item in selected_items # type: ignore - ) + widgets = (self.itemWidget(item) for item in selected_items) return list(itertools.chain.from_iterable(w.device_list.all_configs() for w in widgets)) def mimeTypes(self): @@ -51,6 +67,8 @@ def setupUi(self, availableDeviceResources): self.verticalLayout = QVBoxLayout(availableDeviceResources) self.verticalLayout.setObjectName("verticalLayout") + self._add_toolbar() + self.search_layout = QHBoxLayout() self.verticalLayout.addLayout(self.search_layout) self.search_layout.addWidget(QLabel("Filter groups: ")) @@ -82,3 +100,23 @@ def setupUi(self, availableDeviceResources): self.verticalLayout.addWidget(self.device_groups_list) QMetaObject.connectSlotsByName(availableDeviceResources) + + def _add_toolbar(self): + self.toolbar = ModularToolBar(self) + io_bundle = ToolbarBundle("IO", self.toolbar.components) + self.toolbar.add_bundle(io_bundle) + + self.tb_add_selected = MaterialIconAction( + icon_name="add_box", parent=self, tooltip="Add selected devices to composition" + ) + self.toolbar.components.add_safe("add_selected", self.tb_add_selected) + io_bundle.add_action("add_selected") + + self.tb_del_selected = MaterialIconAction( + icon_name="chips", parent=self, tooltip="Remove selected devices from composition" + ) + self.toolbar.components.add_safe("del_selected", self.tb_del_selected) + io_bundle.add_action("del_selected") + + self.verticalLayout.addWidget(self.toolbar) + self.toolbar.show_bundles(["IO"]) From 9f755997e2a582e615107c9a420ba58f07c1cce8 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 5 Sep 2025 10:23:35 +0200 Subject: [PATCH 062/161] fix: device table theming --- .../components/device_table_view.py | 116 +++++++++--------- 1 file changed, 55 insertions(+), 61 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 2b87707b8..dd9224167 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -5,11 +5,15 @@ import copy import json from contextlib import contextmanager +from functools import partial from typing import TYPE_CHECKING, Any, Iterable, List +from unittest.mock import MagicMock, patch from uuid import uuid4 from bec_lib.logger import bec_logger from bec_qthemes import material_icon +from PySide6.QtCore import QPoint, QRect, QSize +from PySide6.QtWidgets import QStyle, QStyleOption, QStyleOptionViewItem, QWidget from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox @@ -33,6 +37,9 @@ # Threshold for fuzzy matching, careful with adjusting this. 80 seems good FUZZY_SEARCH_THRESHOLD = 80 +# +USER_CHECK_DATA_ROLE = 101 + class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): """Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip.""" @@ -49,68 +56,72 @@ def helpEvent(self, event, view, option, index): return True -class CenterCheckBoxDelegate(DictToolTipDelegate): +class CustomDisplayDelegate(DictToolTipDelegate): + _paint_test_role = Qt.ItemDataRole.DisplayRole + + def displayText(self, value: Any, locale: QtCore.QLocale | QtCore.QLocale.Language) -> str: + return "" + + def _test_custom_paint(self, painter, option, index): + v = index.model().data(index, self._paint_test_role) + return (v is not None), v + + def _do_custom_paint(self, painter, option, index, value): ... + + def paint(self, painter, option, index) -> None: + (check, value) = self._test_custom_paint(painter, option, index) + if not check: + return super().paint(painter, option, index) + super().paint(painter, option, index) + painter.save() + self._do_custom_paint(painter, option, index, value) + painter.restore() + + +class CenterCheckBoxDelegate(CustomDisplayDelegate): """Custom checkbox delegate to center checkboxes in table cells.""" + _paint_test_role = USER_CHECK_DATA_ROLE + def __init__(self, parent=None, colors=None): super().__init__(parent) self._colors: AccentColors = colors if colors else get_accent_colors() # type: ignore - self._icon_checked = material_icon( - "check_box", size=QtCore.QSize(16, 16), color=self._colors.default, filled=True - ) - self._icon_unchecked = material_icon( - "check_box_outline_blank", - size=QtCore.QSize(16, 16), - color=self._colors.default, - filled=True, - ) + _icon = partial(material_icon, size=(16, 16), color=self._colors.default, filled=True) + self._icon_checked = _icon("check_box") + self._icon_unchecked = _icon("check_box_outline_blank") def apply_theme(self, theme: str | None = None): colors = get_accent_colors() self._icon_checked.setColor(colors.default) self._icon_unchecked.setColor(colors.default) - def paint(self, painter, option, index): - value = index.model().data(index, Qt.ItemDataRole.CheckStateRole) - if value is None: - super().paint(painter, option, index) - return - - # Choose icon based on state + def _do_custom_paint(self, painter, option, index, value): pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked - - # Draw icon centered - rect = option.rect pix_rect = pixmap.rect() - pix_rect.moveCenter(rect.center()) + pix_rect.moveCenter(option.rect.center()) painter.drawPixmap(pix_rect.topLeft(), pixmap) def editorEvent(self, event, model, option, index): if event.type() != QtCore.QEvent.Type.MouseButtonRelease: return False - current = model.data(index, Qt.ItemDataRole.CheckStateRole) + current = model.data(index, USER_CHECK_DATA_ROLE) new_state = ( Qt.CheckState.Unchecked if current == Qt.CheckState.Checked else Qt.CheckState.Checked ) - return model.setData(index, new_state, Qt.ItemDataRole.CheckStateRole) + return model.setData(index, new_state, USER_CHECK_DATA_ROLE) -class DeviceValidatedDelegate(DictToolTipDelegate): +class DeviceValidatedDelegate(CustomDisplayDelegate): """Custom delegate for displaying validated device configurations.""" def __init__(self, parent=None, colors=None): super().__init__(parent) self._colors = colors if colors else get_accent_colors() + _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) self._icons = { - ValidationStatus.PENDING: material_icon( - icon_name="circle", size=(12, 12), color=self._colors.default, filled=True - ), - ValidationStatus.VALID: material_icon( - icon_name="circle", size=(12, 12), color=self._colors.success, filled=True - ), - ValidationStatus.FAILED: material_icon( - icon_name="circle", size=(12, 12), color=self._colors.emergency, filled=True - ), + ValidationStatus.PENDING: _icon(color=self._colors.default), + ValidationStatus.VALID: _icon(color=self._colors.success), + ValidationStatus.FAILED: _icon(color=self._colors.emergency), } def apply_theme(self, theme: str | None = None): @@ -118,40 +129,26 @@ def apply_theme(self, theme: str | None = None): for status, icon in self._icons.items(): icon.setColor(colors[status]) - def paint(self, painter, option, index): - status = index.model().data(index, Qt.ItemDataRole.DisplayRole) - if status is None: - return super().paint(painter, option, index) - - pixmap = self._icons.get(status) - if pixmap: - rect = option.rect + def _do_custom_paint(self, painter, option, index, value): + if pixmap := self._icons.get(value): pix_rect = pixmap.rect() - pix_rect.moveCenter(rect.center()) + pix_rect.moveCenter(option.rect.center()) painter.drawPixmap(pix_rect.topLeft(), pixmap) - super().paint(painter, option, index) - -class WrappingTextDelegate(DictToolTipDelegate): +class WrappingTextDelegate(CustomDisplayDelegate): """Custom delegate for wrapping text in table cells.""" def __init__(self, table: BECTableView, parent=None): super().__init__(parent) self._table = table - def paint(self, painter, option, index): - text = index.model().data(index, Qt.ItemDataRole.DisplayRole) - if not text: - return super().paint(painter, option, index) - - painter.save() + def _do_custom_paint(self, painter, option, index, value): painter.setClipRect(option.rect) text_option = ( Qt.TextFlag.TextWordWrap | Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop ) - painter.drawText(option.rect.adjusted(4, 2, -5, -2), text_option, text) - painter.restore() + painter.drawText(option.rect.adjusted(4, 2, -5, -2), text_option, value) def sizeHint(self, option, index): text = str(index.model().data(index, Qt.ItemDataRole.DisplayRole) or "") @@ -161,7 +158,6 @@ def sizeHint(self, option, index): min_width = option.fontMetrics.averageCharWidth() * 4 if column_width < min_width: fm = QtGui.QFontMetrics(option.font) - elided = fm.elidedText(text, Qt.ElideRight, column_width) return QtCore.QSize(column_width, fm.height() + 4) doc = QtGui.QTextDocument() @@ -199,7 +195,7 @@ def __init__(self, parent=None): self._checkable_columns_enabled = {"enabled": True, "readOnly": True} ############################################### - ########## Overwrite custom Qt methods ######## + ########## Override custom Qt methods ######### ############################################### def rowCount(self, parent: QModelIndex | QPersistentModelIndex = QtCore.QModelIndex()) -> int: @@ -242,7 +238,7 @@ def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)): if key == "deviceClass": return str(value).split(".")[-1] return str(value) if value is not None else "" - if role == Qt.ItemDataRole.CheckStateRole and key in ("enabled", "readOnly"): + if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly"): return Qt.CheckState.Checked if value else Qt.CheckState.Unchecked if role == Qt.ItemDataRole.TextAlignmentRole: if key in ("enabled", "readOnly"): @@ -285,13 +281,11 @@ def setData(self, index, value, role=int(Qt.ItemDataRole.EditRole)) -> bool: if not index.isValid(): return False key = self.headers[index.column()] - row = index.row() - - if key in ("enabled", "readOnly") and role == Qt.ItemDataRole.CheckStateRole: + if key in ("enabled", "readOnly") and role == USER_CHECK_DATA_ROLE: if not self._checkable_columns_enabled.get(key, True): return False # ignore changes if column is disabled - self._device_config[row][key] = value == Qt.CheckState.Checked - self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole]) + self._device_config[index.row()][key] = value == Qt.CheckState.Checked + self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole, USER_CHECK_DATA_ROLE]) return True return False From 97fddf01965ec9979cb77feb619035e6426fe1aa Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 5 Sep 2025 12:23:41 +0200 Subject: [PATCH 063/161] refactor: genericise config form --- bec_widgets/utils/forms_from_types/items.py | 16 +- .../services/device_browser/device_browser.py | 8 +- .../device_item/device_config_dialog.py | 170 +++++++++--------- .../device_browser/device_item/device_item.py | 4 +- .../test_device_config_form_dialog.py | 6 +- 5 files changed, 106 insertions(+), 98 deletions(-) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index e9d92db6f..c9d838011 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -442,10 +442,17 @@ def _add_data_item(self, val=None): self._add_list_item(val) self._repop(self._data) + def _item_height(self): + return int(QFontMetrics(self.font()).height() * 1.5) + def _add_list_item(self, val): item = QListWidgetItem(self._main_widget) item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEditable) item_widget = self._types.widget(parent=self) + item_widget.setMinimumHeight(self._item_height()) + self._main_widget.setGridSize(QSize(0, self._item_height())) + if (layout := item_widget.layout()) is not None: + layout.setContentsMargins(0, 0, 0, 0) WidgetIO.set_value(item_widget, val) self._main_widget.setItemWidget(item, item_widget) self._main_widget.addItem(item) @@ -482,14 +489,11 @@ def setValue(self, value: Iterable): self._data = list(value) self._repop(self._data) - def _line_height(self): - return QFontMetrics(self._main_widget.font()).height() - def set_max_height_in_lines(self, lines: int): outer_inc = 1 if self._spec.pretty_display else 3 - self._main_widget.setFixedHeight(self._line_height() * max(lines, self._min_lines)) - self._button_holder.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + 1)) - self.setFixedHeight(self._line_height() * (max(lines, self._min_lines) + outer_inc)) + self._main_widget.setFixedHeight(self._item_height() * max(lines, self._min_lines)) + self._button_holder.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + 1)) + self.setFixedHeight(self._item_height() * (max(lines, self._min_lines) + outer_inc)) def scale_to_data(self, *_): self.set_max_height_in_lines(self._main_widget.count() + 1) diff --git a/bec_widgets/widgets/services/device_browser/device_browser.py b/bec_widgets/widgets/services/device_browser/device_browser.py index 3bf106ac1..2a75217a0 100644 --- a/bec_widgets/widgets/services/device_browser/device_browser.py +++ b/bec_widgets/widgets/services/device_browser/device_browser.py @@ -1,6 +1,4 @@ import os -import re -from functools import partial from typing import Callable import bec_lib @@ -21,7 +19,7 @@ from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.services.device_browser.device_item import DeviceItem from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, ) from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon @@ -109,7 +107,7 @@ def _setup_button(button: QToolButton, icon: str, slot: Callable, tooltip: str = ) def _create_add_dialog(self): - dialog = DeviceConfigDialog(parent=self, device=None, action="add") + dialog = DirectUpdateDeviceConfigDialog(parent=self, device=None, action="add") dialog.open() def on_device_update(self, action: ConfigAction, content: dict) -> None: @@ -134,7 +132,7 @@ def init_device_list(self): def _add_item_to_list(self, device: str, device_obj): - device_item = self.dev_list.add_item( + _, device_item = self.dev_list.add_item( id=device, parent=self, device=device, 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 d81a2beb5..2fc8f70e7 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 @@ -1,12 +1,12 @@ from ast import literal_eval -from typing import Literal +from typing import Any, Literal from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS from bec_lib.config_helper import ConfigHelper from bec_lib.logger import bec_logger -from pydantic import ValidationError, field_validator -from qtpy.QtCore import QSize, Qt, QThreadPool, Signal +from pydantic import field_validator +from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore from qtpy.QtWidgets import ( QApplication, QDialog, @@ -29,6 +29,8 @@ logger = bec_logger.logger +_StdBtn = QDialogButtonBox.StandardButton + def _try_literal_eval(value: str): if value == "": @@ -39,20 +41,11 @@ def _try_literal_eval(value: str): raise ValueError(f"Entered config value {value} is not a valid python value!") from e -class DeviceConfigDialog(BECWidget, QDialog): +class DeviceConfigDialog(QDialog): RPC = False applied = Signal() - def __init__( - self, - *, - parent=None, - device: str | None = None, - config_helper: ConfigHelper | None = None, - action: Literal["update", "add"] = "update", - threadpool: QThreadPool | None = None, - **kwargs, - ): + def __init__(self, *, parent=None, **kwargs): """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model for device specification in bec_lib.atlas_models. @@ -64,71 +57,26 @@ def __init__( """ self._initial_config = {} super().__init__(parent=parent, **kwargs) - self._config_helper = config_helper or ConfigHelper( - self.client.connector, self.client._service_name - ) - self._device = device - self._action: Literal["update", "add"] = action - self._q_threadpool = threadpool or QThreadPool() - self.setWindowTitle(f"Edit config for: {device}") + self._container = QStackedLayout() - self._container.setStackingMode(QStackedLayout.StackAll) + self._container.setStackingMode(QStackedLayout.StackingMode.StackAll) self._layout = QVBoxLayout() - user_warning = QLabel( - "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" - "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." - ) - user_warning.setWordWrap(True) - user_warning.setStyleSheet("QLabel { color: red; }") - self._layout.addWidget(user_warning) - self.get_bec_shortcuts() + self._add_form() - if self._action == "update": - self._form._validity.setVisible(False) - else: - self._set_schema_to_check_devices() - # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - # self._form._validity.setVisible(True) - self._form.validity_proc.connect(self.enable_buttons_for_validity) self._add_overlay() self._add_buttons() - + self.setWindowTitle("Add new device") self.setLayout(self._container) self._form.validate_form() self._overlay_widget.setVisible(False) - def _set_schema_to_check_devices(self): - class _NameValidatedConfigModel(DeviceConfigModel): - @field_validator("name") - @staticmethod - def _validate_name(value: str, *_): - if not value.isidentifier(): - raise ValueError( - f"Invalid device name: {value}. Device names must be valid Python identifiers." - ) - if value in self.dev: - raise ValueError(f"A device with name {value} already exists!") - return value - - self._form.set_schema(_NameValidatedConfigModel) - def _add_form(self): self._form_widget = QWidget() self._form_widget.setLayout(self._layout) self._form = DeviceConfigForm() self._layout.addWidget(self._form) - for row in self._form.enumerate_form_widgets(): - if ( - row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE - and self._action == "update" - ): - row.widget._set_pretty_display() - - if self._action == "update" and self._device in self.dev: - self._fetch_config() - self._fill_form() self._container.addWidget(self._form_widget) def _add_overlay(self): @@ -145,21 +93,12 @@ def _add_overlay(self): self._container.addWidget(self._overlay_widget) def _add_buttons(self): - self.button_box = QDialogButtonBox( - QDialogButtonBox.Apply | QDialogButtonBox.Ok | QDialogButtonBox.Cancel - ) - self.button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply) + self.button_box = QDialogButtonBox(_StdBtn.Apply | _StdBtn.Ok | _StdBtn.Cancel) + self.button_box.button(_StdBtn.Apply).clicked.connect(self.apply) self.button_box.accepted.connect(self.accept) self.button_box.rejected.connect(self.reject) self._layout.addWidget(self.button_box) - def _fetch_config(self): - if ( - self.client.device_manager is not None - and self._device in self.client.device_manager.devices - ): - self._initial_config = self.client.device_manager.devices.get(self._device)._config - def _fill_form(self): self._form.set_data(DeviceConfigModel.model_validate(self._initial_config)) @@ -190,12 +129,12 @@ def updated_config(self): @SafeSlot(bool) def enable_buttons_for_validity(self, valid: bool): # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved - for button in [ - self.button_box.button(b) for b in [QDialogButtonBox.Apply, QDialogButtonBox.Ok] - ]: + for button in [self.button_box.button(b) for b in [_StdBtn.Apply, _StdBtn.Ok]]: button.setEnabled(valid) button.setToolTip(self._form._validity_message.text()) + def _process_action(self): ... + @SafeSlot(popup_error=True) def apply(self): self._process_action() @@ -206,10 +145,77 @@ def accept(self): self._process_action() return super().accept() + +class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog): + def __init__( + self, + *, + parent=None, + device: str | None = None, + config_helper: ConfigHelper | None = None, + action: Literal["update"] | Literal["add"] = "update", + threadpool: QThreadPool | None = None, + **kwargs, + ): + self._device = device + self._q_threadpool = threadpool or QThreadPool() + self._config_helper = config_helper or ConfigHelper( + self.client.connector, self.client._service_name + ) + super().__init__(parent=parent, **kwargs) + self.get_bec_shortcuts() + self._action: Literal["update", "add"] = action + user_warning = QLabel( + "Warning: edit items here at your own risk - minimal validation is applied to the entered values.\n" + "Items in the deviceConfig dictionary should correspond to python literals, e.g. numbers, lists, strings (including quotes), etc." + ) + user_warning.setWordWrap(True) + user_warning.setStyleSheet("QLabel { color: red; }") + self._layout.insertWidget(0, user_warning) + self.setWindowTitle( + f"Edit config for: {device}" if action == "update" else "Add new device" + ) + + if self._action == "update": + self._modify_for_update() + else: + self._set_schema_to_check_devices() + # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved + # self._form._validity.setVisible(True) + self._form.validity_proc.connect(self.enable_buttons_for_validity) + + def _modify_for_update(self): + for row in self._form.enumerate_form_widgets(): + if row.label.property("_model_field_name") in DEVICE_CONF_KEYS.NON_UPDATABLE: + row.widget._set_pretty_display() + if self._device in self.dev: + self._fetch_config() + self._fill_form() + self._form._validity.setVisible(False) + + def _set_schema_to_check_devices(self): + class _NameValidatedConfigModel(DeviceConfigModel): + @field_validator("name") + @staticmethod + def _validate_name(value: str, *_): + if not value.isidentifier(): + raise ValueError( + f"Invalid device name: {value}. Device names must be valid Python identifiers." + ) + if value in self.dev: + raise ValueError(f"A device with name {value} already exists!") + return value + + self._form.set_schema(_NameValidatedConfigModel) + + def _fetch_config(self): + if self.dev is not None and (device := self.dev.get(self._device)) is not None: # type: ignore + self._initial_config = device._config + def _process_action(self): updated_config = self.updated_config() if self._action == "add": - if (name := updated_config.get("name")) in self.dev: + if self.dev is not None and (name := updated_config.get("name")) in self.dev: raise ValueError( f"Can't create a new device with the same name as already existing device {name}!" ) @@ -269,10 +275,10 @@ def main(): # pragma: no cover app = QApplication(sys.argv) apply_theme("light") widget = QWidget() - widget.setLayout(QVBoxLayout()) + widget.setLayout(layout := QVBoxLayout()) device = QLineEdit() - widget.layout().addWidget(device) + layout.addWidget(device) def _destroy_dialog(*_): nonlocal dialog @@ -286,13 +292,13 @@ def _show_dialog(*_): nonlocal dialog if dialog is None: kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} - dialog = DeviceConfigDialog(**kwargs) + dialog = DirectUpdateDeviceConfigDialog(**kwargs) # type: ignore dialog.accepted.connect(accept) dialog.rejected.connect(_destroy_dialog) dialog.open() button = QPushButton("Show device dialog") - widget.layout().addWidget(button) + layout.addWidget(button) button.clicked.connect(_show_dialog) widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_item.py b/bec_widgets/widgets/services/device_browser/device_item/device_item.py index f24e2fd24..45f233cb6 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_item.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_item.py @@ -18,7 +18,7 @@ CommunicateConfigAction, ) from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, ) from bec_widgets.widgets.services.device_browser.device_item.device_config_form import ( DeviceConfigForm, @@ -91,7 +91,7 @@ def _create_title_layout(self, title: str, icon: str): @SafeSlot() def _create_edit_dialog(self): - dialog = DeviceConfigDialog( + dialog = DirectUpdateDeviceConfigDialog( parent=self, device=self.device, config_helper=self._config_helper, diff --git a/tests/unit_tests/test_device_config_form_dialog.py b/tests/unit_tests/test_device_config_form_dialog.py index e176da5b3..22d712268 100644 --- a/tests/unit_tests/test_device_config_form_dialog.py +++ b/tests/unit_tests/test_device_config_form_dialog.py @@ -6,7 +6,7 @@ from bec_widgets.utils.forms_from_types.items import StrFormItem from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, _try_literal_eval, ) @@ -29,7 +29,7 @@ def mock_client(): @pytest.fixture def update_dialog(mock_client, qtbot): """Fixture to create a DeviceConfigDialog instance.""" - update_dialog = DeviceConfigDialog( + update_dialog = DirectUpdateDeviceConfigDialog( device="test_device", config_helper=MagicMock(), client=mock_client ) qtbot.addWidget(update_dialog) @@ -39,7 +39,7 @@ def update_dialog(mock_client, qtbot): @pytest.fixture def add_dialog(mock_client, qtbot): """Fixture to create a DeviceConfigDialog instance.""" - add_dialog = DeviceConfigDialog( + add_dialog = DirectUpdateDeviceConfigDialog( device=None, config_helper=MagicMock(), client=mock_client, action="add" ) qtbot.addWidget(add_dialog) From 5fde098974fc1475596c4bfe4360c08c29032178 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 5 Sep 2025 16:32:14 +0200 Subject: [PATCH 064/161] feat(device_manager): add device dialog with presets --- .../device_manager_view.py | 16 +++- bec_widgets/utils/forms_from_types/forms.py | 28 +++++- bec_widgets/utils/forms_from_types/items.py | 17 +++- .../device_item/device_config_dialog.py | 90 +++++++++++++++---- .../device_item/device_config_form.py | 27 +++++- 5 files changed, 151 insertions(+), 27 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index 7ff81c0ff..980a46dd1 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -29,6 +29,10 @@ from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import ( AvailableDeviceResources, ) +from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( + DeviceConfigDialog, + PresetClassDeviceConfigDialog, +) logger = bec_logger.logger @@ -277,7 +281,6 @@ def _add_table_actions(self) -> None: # - rerun validation (with/without connect) # IO actions - def _coming_soon(self): return QMessageBox.question( self, @@ -377,15 +380,20 @@ def _reset_composed_view(self): if reply == QMessageBox.StandardButton.Yes: self.device_table_view.clear_device_configs() - # TODO Here we would like to implement a custom popup view, that allows to add new devices - # We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device + # TODO We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device # For all default Epics devices, we would like to preselect relevant fields, and prompt them with the proper deviceConfig args already, i.e. 'prefix', 'read_pv', 'write_pv' etc.. # For custom Device, they should receive all options. It might be cool to get a side panel with docstring view of the class upon inspecting it to make it easier in case deviceConfig entries are required.. @SafeSlot() def _add_device_action(self): """Action for the 'add_device' action to add a new device.""" # Implement the logic to add a new device - reply = self._coming_soon() + dialog = PresetClassDeviceConfigDialog(parent=self) + dialog.accepted_data.connect(self._add_to_table_from_dialog) + dialog.open() + + @SafeSlot(dict) + def _add_to_table_from_dialog(self, data): + self.device_table_view.add_device_configs([data]) @SafeSlot() def _remove_device_action(self): diff --git a/bec_widgets/utils/forms_from_types/forms.py b/bec_widgets/utils/forms_from_types/forms.py index db5ad7dea..382c40038 100644 --- a/bec_widgets/utils/forms_from_types/forms.py +++ b/bec_widgets/utils/forms_from_types/forms.py @@ -1,6 +1,6 @@ from __future__ import annotations -from types import NoneType +from types import GenericAlias, NoneType, UnionType from typing import NamedTuple from bec_lib.logger import bec_logger @@ -11,7 +11,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.compact_popup import CompactPopupWidget -from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.forms_from_types import styles from bec_widgets.utils.forms_from_types.items import ( DynamicFormItem, @@ -216,6 +216,9 @@ def __init__( self._connect_to_theme_change() + @SafeSlot() + def clear(self): ... + def set_pretty_display_theme(self, theme: str = "dark"): if self._pretty_display: self.setStyleSheet(styles.pretty_display_theme(theme)) @@ -280,3 +283,24 @@ def validate_form(self, *_) -> bool: self.form_data_cleared.emit(None) self.validity_proc.emit(False) return False + + +class PydanticModelFormItem(DynamicFormItem): + def __init__( + self, parent: QWidget | None = None, *, spec: FormItemSpec, model: type[BaseModel] + ) -> None: + self._data_model = model + + super().__init__(parent=parent, spec=spec) + self._main_widget.form_data_updated.connect(self._value_changed) + + def _add_main_widget(self) -> None: + + self._main_widget = PydanticModelForm(data_model=self._data_model) + self._layout.addWidget(self._main_widget) + + def getValue(self): + return self._main_widget.get_form_data() + + def setValue(self, value: dict): + self._main_widget.set_data(self._data_model.model_validate(value)) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index c9d838011..ad06596dc 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect import typing from abc import abstractmethod from decimal import Decimal @@ -12,8 +13,10 @@ Literal, NamedTuple, OrderedDict, + Protocol, TypeVar, get_args, + runtime_checkable, ) from bec_lib.logger import bec_logger @@ -561,7 +564,14 @@ def clear(self): self._main_widget.setCurrentIndex(-1) -WidgetTypeRegistry = OrderedDict[str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem]]] +@runtime_checkable +class _ItemTypeFn(Protocol): + def __call__(self, spec: FormItemSpec) -> type[DynamicFormItem]: ... + + +WidgetTypeRegistry = OrderedDict[ + str, tuple[Callable[[FormItemSpec], bool], type[DynamicFormItem] | _ItemTypeFn] +] DEFAULT_WIDGET_TYPES: Final[WidgetTypeRegistry] = OrderedDict() | { # dict literals are ordered already but TypedForm subclasses may modify coppies of this dict @@ -602,7 +612,10 @@ def widget_from_type( widget_types = widget_types or DEFAULT_WIDGET_TYPES for predicate, widget_type in widget_types.values(): if predicate(spec): - return widget_type + if inspect.isclass(widget_type) and issubclass(widget_type, DynamicFormItem): + return widget_type + return widget_type(spec) + logger.warning( f"Type {spec.item_type=} / {spec.info.annotation=} is not (yet) supported in dynamic form creation." ) 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 2fc8f70e7..927dda1d0 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 @@ -5,7 +5,8 @@ from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS from bec_lib.config_helper import ConfigHelper from bec_lib.logger import bec_logger -from pydantic import field_validator +from pydantic import BaseModel, field_validator +from PySide6.QtWidgets import QComboBox, QHBoxLayout from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore from qtpy.QtWidgets import ( QApplication, @@ -19,6 +20,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.forms_from_types.items import DynamicFormItem, DynamicFormItemType from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( CommunicateConfigAction, ) @@ -44,32 +46,33 @@ def _try_literal_eval(value: str): class DeviceConfigDialog(QDialog): RPC = False applied = Signal() + accepted_data = Signal(dict) - def __init__(self, *, parent=None, **kwargs): - """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model - for device specification in bec_lib.atlas_models. + def __init__( + self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | None = None, **kwargs + ): - Args: - parent (QObject): the parent QObject - device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. - config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. - action (Literal["update", "add"]): the action which the form should perform on application or acceptance. - """ self._initial_config = {} + self._class_deviceconfig_item = class_deviceconfig_item super().__init__(parent=parent, **kwargs) self._container = QStackedLayout() self._container.setStackingMode(QStackedLayout.StackingMode.StackAll) self._layout = QVBoxLayout() - + self._data = {} self._add_form() self._add_overlay() self._add_buttons() self.setWindowTitle("Add new device") self.setLayout(self._container) - self._form.validate_form() self._overlay_widget.setVisible(False) + self._form._validity.setVisible(True) + self._connect_form() + + def _connect_form(self): + self._form.validity_proc.connect(self.enable_buttons_for_validity) + self._form.validate_form() def _add_form(self): self._form_widget = QWidget() @@ -133,7 +136,11 @@ def enable_buttons_for_validity(self, valid: bool): button.setEnabled(valid) button.setToolTip(self._form._validity_message.text()) - def _process_action(self): ... + def _process_action(self): + self.accepted_data.emit(self._form.get_form_data()) + + def get_data(self): + return self._data @SafeSlot(popup_error=True) def apply(self): @@ -146,6 +153,48 @@ def accept(self): return super().accept() +class EpicsDeviceConfig(BaseModel): + prefix: str + + +class PresetClassDeviceConfigDialog(DeviceConfigDialog): + def __init__(self, *, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self._create_selection_box() + self._selection_box.currentTextChanged.connect(self._replace_form) + self._device_models = { + "Custom": (None, {}), + "EpicsMotor": (EpicsDeviceConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}), + "EpicsSignal": (EpicsDeviceConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}), + } + + def _apply_constraints(self, constraints: dict[str, tuple[DynamicFormItemType, bool]]): + for field_name, (value, editable) in constraints.items(): + if (widget := self._form.widget_dict.get(field_name)) is not None: + widget.setValue(value) + if not editable: + widget._set_pretty_display() + + def _replace_form(self, deviceconfig_cls_key): + self._form.deleteLater() + if (devmodel_params := self._device_models.get(deviceconfig_cls_key)) is not None: + devmodel, params = devmodel_params + else: + devmodel, params = None, {} + self._form = DeviceConfigForm(class_deviceconfig_item=devmodel) + self._apply_constraints(params) + self._layout.insertWidget(1, self._form) + self._connect_form() + + def _create_selection_box(self): + layout = QHBoxLayout() + self._selection_box = QComboBox() + self._selection_box.addItems(["Custom", "EpicsMotor", "EpicsSignal"]) + layout.addWidget(QLabel("Choose a device class: ")) + layout.addWidget(self._selection_box) + self._layout.insertLayout(0, layout) + + class DirectUpdateDeviceConfigDialog(BECWidget, DeviceConfigDialog): def __init__( self, @@ -157,6 +206,15 @@ def __init__( threadpool: QThreadPool | None = None, **kwargs, ): + """A dialog to edit the configuration of a device in BEC. Generated from the pydantic model + for device specification in bec_lib.atlas_models. + + Args: + parent (QObject): the parent QObject + device (str | None): the name of the device. used with the "update" action to prefill the dialog and validate entries. + config_helper (ConfigHelper | None): a ConfigHelper object for communication with Redis, will be created if necessary. + action (Literal["update", "add"]): the action which the form should perform on application or acceptance. + """ self._device = device self._q_threadpool = threadpool or QThreadPool() self._config_helper = config_helper or ConfigHelper( @@ -178,11 +236,11 @@ def __init__( if self._action == "update": self._modify_for_update() + self._form.validity_proc.disconnect(self.enable_buttons_for_validity) else: self._set_schema_to_check_devices() # TODO: replace when https://github.com/bec-project/bec/issues/528 https://github.com/bec-project/bec/issues/547 are resolved # self._form._validity.setVisible(True) - self._form.validity_proc.connect(self.enable_buttons_for_validity) def _modify_for_update(self): for row in self._form.enumerate_form_widgets(): @@ -291,8 +349,8 @@ def accept(*args): def _show_dialog(*_): nonlocal dialog if dialog is None: - kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} - dialog = DirectUpdateDeviceConfigDialog(**kwargs) # type: ignore + kwargs = {} # kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} + dialog = PresetClassDeviceConfigDialog(**kwargs) # type: ignore dialog.accepted.connect(accept) dialog.rejected.connect(_destroy_dialog) dialog.open() diff --git a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py index 0b8c1aeb0..a783d9883 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py +++ b/bec_widgets/widgets/services/device_browser/device_item/device_config_form.py @@ -1,16 +1,20 @@ from __future__ import annotations +from functools import partial + from bec_lib.atlas_models import Device as DeviceConfigModel from pydantic import BaseModel from qtpy.QtWidgets import QApplication from bec_widgets.utils.colors import get_theme_name from bec_widgets.utils.forms_from_types import styles -from bec_widgets.utils.forms_from_types.forms import PydanticModelForm +from bec_widgets.utils.forms_from_types.forms import PydanticModelForm, PydanticModelFormItem from bec_widgets.utils.forms_from_types.items import ( DEFAULT_WIDGET_TYPES, BoolFormItem, BoolToggleFormItem, + DictFormItem, + FormItemSpec, ) @@ -18,7 +22,14 @@ class DeviceConfigForm(PydanticModelForm): RPC = False PLUGIN = False - def __init__(self, parent=None, client=None, pretty_display=False, **kwargs): + def __init__( + self, + parent=None, + client=None, + pretty_display=False, + class_deviceconfig_item: type[BaseModel] | None = None, + **kwargs, + ): super().__init__( parent=parent, data_model=DeviceConfigModel, @@ -26,18 +37,28 @@ def __init__(self, parent=None, client=None, pretty_display=False, **kwargs): client=client, **kwargs, ) + self._class_deviceconfig_item: type[BaseModel] | None = class_deviceconfig_item self._widget_types = DEFAULT_WIDGET_TYPES.copy() self._widget_types["bool"] = (lambda spec: spec.item_type is bool, BoolToggleFormItem) self._widget_types["optional_bool"] = ( lambda spec: spec.item_type == bool | None, BoolFormItem, ) - self._validity.setVisible(False) + pred, _ = self._widget_types["dict"] + self._widget_types["dict"] = pred, self._custom_device_config_item + self._validity.setVisible(True) self._connect_to_theme_change() self.populate() def _post_init(self): ... + def _custom_device_config_item(self, spec: FormItemSpec): + if spec.name != "deviceConfig": + return DictFormItem + if self._class_deviceconfig_item is not None: + return partial(PydanticModelFormItem, model=self._class_deviceconfig_item) + return DictFormItem + def set_pretty_display_theme(self, theme: str | None = None): if theme is None: theme = get_theme_name() From bd9be10aafc5dcfbf8cd7f066224bd073a7b72f6 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 8 Sep 2025 08:43:50 +0200 Subject: [PATCH 065/161] fix: don't use deprecated api for CDockWidget --- .../device_manager_view.py | 24 ++++++++++--------- .../available_device_resources_ui.py | 2 +- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index 980a46dd1..43776845f 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -91,29 +91,31 @@ def __init__(self, parent=None, *args, **kwargs): self.available_devices = AvailableDeviceResources( self, shared_selection_signal=self._shared_selection ) - self.available_devices_dock = QtAds.CDockWidget("Available Devices", self) + self.available_devices_dock = QtAds.CDockWidget( + self.dock_manager, "Available Devices", self + ) self.available_devices_dock.setWidget(self.available_devices) # Device Table View widget self.device_table_view = DeviceTableView( self, shared_selection_signal=self._shared_selection ) - self.device_table_view_dock = QtAds.CDockWidget("Device Table", self) + self.device_table_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Table", self) self.device_table_view_dock.setWidget(self.device_table_view) # Device Config View widget self.dm_config_view = DMConfigView(self) - self.dm_config_view_dock = QtAds.CDockWidget("Device Config View", self) + self.dm_config_view_dock = QtAds.CDockWidget(self.dock_manager, "Device Config View", self) self.dm_config_view_dock.setWidget(self.dm_config_view) # Docstring View self.dm_docs_view = DocstringView(self) - self.dm_docs_view_dock = QtAds.CDockWidget("Docstring View", self) + self.dm_docs_view_dock = QtAds.CDockWidget(self.dock_manager, "Docstring View", self) self.dm_docs_view_dock.setWidget(self.dm_docs_view) # Ophyd Test view self.ophyd_test_view = DMOphydTest(self) - self.ophyd_test_dock_view = QtAds.CDockWidget("Ophyd Test View", self) + self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self) self.ophyd_test_dock_view.setWidget(self.ophyd_test_view) # Arrange widgets within the QtAds dock manager @@ -203,9 +205,6 @@ def _add_io_actions(self): # Create IO bundle io_bundle = ToolbarBundle("IO", self.toolbar.components) - # Add load config from plugin dir - self.toolbar.add_bundle(io_bundle) - load = MaterialIconAction( icon_name="file_open", parent=self, tooltip="Load configuration file from disk" ) @@ -237,14 +236,14 @@ def _add_io_actions(self): self.toolbar.components.add_safe("update_config_redis", update_config_redis) io_bundle.add_action("update_config_redis") + # Add load config from plugin dir + self.toolbar.add_bundle(io_bundle) + # Table actions def _add_table_actions(self) -> None: table_bundle = ToolbarBundle("Table", self.toolbar.components) - # Add load config from plugin dir - self.toolbar.add_bundle(table_bundle) - # Reset composed view reset_composed = MaterialIconAction( icon_name="delete_sweep", parent=self, tooltip="Reset current composed config view" @@ -273,6 +272,9 @@ def _add_table_actions(self) -> None: self.toolbar.components.add_safe("rerun_validation", rerun_validation) table_bundle.add_action("rerun_validation") + # Add load config from plugin dir + self.toolbar.add_bundle(table_bundle) + # Most likly, no actions on available devices # Actions (vielleicht bundle fuer available devices ) # - reset composed view diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py index 4cba0bf5a..7f485ba7d 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -104,7 +104,6 @@ def setupUi(self, availableDeviceResources): def _add_toolbar(self): self.toolbar = ModularToolBar(self) io_bundle = ToolbarBundle("IO", self.toolbar.components) - self.toolbar.add_bundle(io_bundle) self.tb_add_selected = MaterialIconAction( icon_name="add_box", parent=self, tooltip="Add selected devices to composition" @@ -119,4 +118,5 @@ def _add_toolbar(self): io_bundle.add_action("del_selected") self.verticalLayout.addWidget(self.toolbar) + self.toolbar.add_bundle(io_bundle) self.toolbar.show_bundles(["IO"]) From 7e3de2c64e58f3c40cbbfb800fbff9d9484ae60b Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 8 Sep 2025 10:43:07 +0200 Subject: [PATCH 066/161] fix: slightly improve theming --- bec_widgets/utils/expandable_frame.py | 4 ++-- bec_widgets/utils/forms_from_types/items.py | 8 +++++--- .../available_device_group.py | 5 ++--- .../available_device_group_ui.py | 12 +++++++++--- .../available_device_resources_ui.py | 2 +- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py index 138dac274..08a4d95f4 100644 --- a/bec_widgets/utils/expandable_frame.py +++ b/bec_widgets/utils/expandable_frame.py @@ -33,10 +33,10 @@ def __init__( self._expanded = expanded self._title_text = f"{title}" - self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain) + self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised) self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) self._layout = QVBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setContentsMargins(5, 0, 0, 0) self.setLayout(self._layout) self._create_title_layout(title, icon) diff --git a/bec_widgets/utils/forms_from_types/items.py b/bec_widgets/utils/forms_from_types/items.py index ad06596dc..b480d3a1a 100644 --- a/bec_widgets/utils/forms_from_types/items.py +++ b/bec_widgets/utils/forms_from_types/items.py @@ -171,9 +171,10 @@ def __init__(self, parent: QWidget | None = None, *, spec: FormItemSpec) -> None self._desc = self._spec.info.description self.setLayout(self._layout) self._add_main_widget() + # Sadly, QWidget and ABC are not compatible assert isinstance(self._main_widget, QWidget), "Please set a widget in _add_main_widget()" # type: ignore - self._main_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) - self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) + self._main_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum) if not spec.pretty_display: if clearable_required(spec.info): self._add_clear_button() @@ -188,6 +189,7 @@ def setValue(self, value): ... @abstractmethod def _add_main_widget(self) -> None: + self._main_widget: QWidget """Add the main data entry widget to self._main_widget and appply any constraints from the field info""" @@ -395,7 +397,7 @@ def __init__(self, *, parent: QWidget | None = None, spec: FormItemSpec) -> None def sizeHint(self): default = super().sizeHint() - return QSize(default.width(), QFontMetrics(self.font()).height() * 6) + return QSize(default.width(), QFontMetrics(self.font()).height() * 4) def _add_main_widget(self) -> None: self._main_widget = QListWidget() diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py index 9b93304b6..96759d7b2 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -39,11 +39,10 @@ def __init__(self, device_spec: HashableDevice, parent=None, **kwargs): self._device_spec = device_spec self.included: bool = False - self.setFrameShape(QFrame.Shape.StyledPanel) - self.setFrameShadow(QFrame.Shadow.Raised) + self.setFrameStyle(0) self._layout = QVBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setContentsMargins(2, 2, 2, 2) self.setLayout(self._layout) self.setup_title_layout(device_spec) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py index f4bbf7844..bea0a1c34 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -1,5 +1,7 @@ +from typing import TYPE_CHECKING + from qtpy.QtCore import QMetaObject, Qt -from qtpy.QtWidgets import QLabel, QListWidget, QVBoxLayout +from qtpy.QtWidgets import QFrame, QLabel, QListWidget, QVBoxLayout from bec_widgets.widgets.control.device_manager.components._util import mimedata_from_configs from bec_widgets.widgets.control.device_manager.components.constants import ( @@ -7,6 +9,9 @@ MIME_DEVICE_CONFIG, ) +if TYPE_CHECKING: + from .available_device_group import AvailableDeviceGroup + class _DeviceListWiget(QListWidget): @@ -24,10 +29,11 @@ def mimeData(self, items): class Ui_AvailableDeviceGroup(object): - def setupUi(self, AvailableDeviceGroup): + def setupUi(self, AvailableDeviceGroup: "AvailableDeviceGroup"): if not AvailableDeviceGroup.objectName(): AvailableDeviceGroup.setObjectName("AvailableDeviceGroup") AvailableDeviceGroup.setMinimumWidth(150) + self.verticalLayout = QVBoxLayout() self.verticalLayout.setObjectName("verticalLayout") AvailableDeviceGroup.set_layout(self.verticalLayout) @@ -46,5 +52,5 @@ def setupUi(self, AvailableDeviceGroup): self.device_list.setAcceptDrops(False) self.device_list.setDefaultDropAction(Qt.DropAction.CopyAction) self.verticalLayout.addWidget(self.device_list) - + AvailableDeviceGroup.setFrameStyle(QFrame.Shadow.Plain | QFrame.Shape.Box) QMetaObject.connectSlotsByName(AvailableDeviceGroup) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py index 7f485ba7d..d136cbc05 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -86,7 +86,7 @@ def setupUi(self, availableDeviceResources): self.device_groups_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.device_groups_list.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.device_groups_list.setMovement(QListView.Movement.Static) - self.device_groups_list.setSpacing(2) + self.device_groups_list.setSpacing(4) self.device_groups_list.setDragDropMode(QListWidget.DragDropMode.DragOnly) self.device_groups_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectItems) self.device_groups_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) From f7f37509b6b052315fe10a98a23ba4425ec50f0b Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 8 Sep 2025 11:25:06 +0200 Subject: [PATCH 067/161] style: typo --- .../examples/device_manager_view/device_manager_view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index 43776845f..dd4a29379 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -217,7 +217,7 @@ def _add_io_actions(self): icon_name="file_save", parent=self, tooltip="Save config to disk" ) self.toolbar.components.add_safe("safe_to_disk", safe_to_disk) - safe_to_disk.action.triggered.connect(self._safe_to_disk_action) + safe_to_disk.action.triggered.connect(self._save_to_disk_action) io_bundle.add_action("safe_to_disk") # Add load config from redis @@ -341,7 +341,7 @@ def _load_redis_action(self): return @SafeSlot() - def _safe_to_disk_action(self): + def _save_to_disk_action(self): """Action for the 'safe_to_disk' action to save the current config to disk.""" # Check if plugin repo is installed... try: From 9cd4e10408ba59986450766caf7f9168105e63d9 Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 10 Sep 2025 09:24:57 +0200 Subject: [PATCH 068/161] feat: allow setting config in redis --- .../device_manager_view.py | 56 ++++++++++++++----- .../components/device_table_view.py | 11 +++- .../components/dm_ophyd_test.py | 3 + .../device_item/config_communicator.py | 9 ++- 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index dd4a29379..5cdea7109 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -1,17 +1,19 @@ from __future__ import annotations import os +from functools import partial from typing import TYPE_CHECKING, List import PySide6QtAds as QtAds import yaml +from bec_lib import config_helper from bec_lib.bec_yaml_loader import yaml_load from bec_lib.file_utils import DeviceConfigWriter from bec_lib.logger import bec_logger from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from bec_qthemes import apply_theme from PySide6QtAds import CDockManager, CDockWidget -from qtpy.QtCore import Qt, QTimer +from qtpy.QtCore import Qt, QThreadPool, QTimer from qtpy.QtWidgets import QFileDialog, QMessageBox, QSplitter, QVBoxLayout, QWidget from bec_widgets import BECWidget @@ -29,13 +31,21 @@ from bec_widgets.widgets.control.device_manager.components.available_device_resources.available_device_resources import ( AvailableDeviceResources, ) +from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( + CommunicateConfigAction, +) from bec_widgets.widgets.services.device_browser.device_item.device_config_dialog import ( - DeviceConfigDialog, PresetClassDeviceConfigDialog, ) logger = bec_logger.logger +_yes_no_question = partial( + QMessageBox.question, + buttons=QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + defaultButton=QMessageBox.StandardButton.No, +) + def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: """ @@ -78,6 +88,7 @@ class DeviceManagerView(BECWidget, QWidget): def __init__(self, parent=None, *args, **kwargs): super().__init__(parent=parent, client=None, *args, **kwargs) + self._config_helper = config_helper.ConfigHelper(self.client.connector) self._shared_selection = SharedSelectionSignal() # Top-level layout hosting a toolbar and the dock manager @@ -326,12 +337,10 @@ def _load_file_action(self): @SafeSlot() def _load_redis_action(self): """Action for the 'load_redis' action to load the current config from Redis for the io_bundle of the toolbar.""" - reply = QMessageBox.question( + reply = _yes_no_question( self, "Load currently active config", "Do you really want to discard the current config and reload?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None: self.device_table_view.set_device_config( @@ -340,6 +349,32 @@ def _load_redis_action(self): else: return + @SafeSlot() + def _update_redis_action(self): + """Action to push the current composition to Redis""" + reply = _yes_no_question( + self, + "Push composition to Redis", + "Do you really want to replace the active configuration in the BEC server with the current composition? ", + ) + if reply != QMessageBox.StandardButton.Yes: + return + if self.device_table_view.table.contains_invalid_devices(): + return QMessageBox.warning( + self, "Validation has errors!", "Please resolve before proceeding." + ) + if self.ophyd_test_view.validation_running(): + return QMessageBox.warning( + self, "Validation has not completed.", "Please wait for the validation to finish." + ) + self._push_compositiion_to_redis() + + def _push_compositiion_to_redis(self): + config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()} + threadpool = QThreadPool.globalInstance() + comm = CommunicateConfigAction(self._config_helper, None, config, "set") + threadpool.start(comm) + @SafeSlot() def _save_to_disk_action(self): """Action for the 'safe_to_disk' action to save the current config to disk.""" @@ -360,24 +395,15 @@ def _save_to_disk_action(self): with open(file_path, "w") as file: file.write(yaml.dump(config)) - # TODO add here logic, should be asyncronous, but probably block UI, and show a loading spinner. If failed, it should report.. - @SafeSlot() - def _update_redis_action(self): - """Action for the 'update_redis' action to update the current config in Redis.""" - config = self.device_table_view.get_device_config() - reply = self._coming_soon() - # Table actions @SafeSlot() def _reset_composed_view(self): """Action for the 'reset_composed_view' action to reset the composed view.""" - reply = QMessageBox.question( + reply = _yes_no_question( self, "Clear View", "You are about to clear the current composed config view, please confirm...", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: self.device_table_view.clear_device_configs() diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index dd9224167..221c835b6 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -313,7 +313,7 @@ def dropMimeData(self, data, action, row, column, parent): def get_device_config(self) -> list[dict[str, Any]]: """Method to get the device configuration.""" - return self._device_config + return copy.deepcopy(self._device_config) def device_names(self, configs: _DeviceCfgIter | None = None) -> set[str]: _configs = self._device_config if configs is None else configs @@ -431,6 +431,9 @@ def update_validation_status(self, device_name: str, status: int | ValidationSta index = self.index(row, 0) self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole]) + def validation_statuses(self): + return copy.deepcopy(self._validation_status) + class BECTableView(QtWidgets.QTableView): """Table View with custom keyPressEvent to delete rows with backspace or delete key""" @@ -455,6 +458,12 @@ def keyPressEvent(self, event) -> None: return self.delete_selected() return super().keyPressEvent(event) + def contains_invalid_devices(self): + return ValidationStatus.FAILED in self.model().sourceModel().validation_statuses().values() + + def all_configs(self): + return self.model().sourceModel().get_device_config() + def selected_configs(self): return self.model().get_row_data(self.selectionModel().selectedRows()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py index 71ef1da53..2093eb6c5 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -373,6 +373,9 @@ def _format_validation_message(self, raw_msg: str) -> str: ) return html + def validation_running(self): + return self._device_list_items != {} + @SafeSlot() def clear_list(self): """Clear the device list.""" diff --git a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py index 4a469dbba..ca1d66f7a 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py +++ b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py @@ -34,7 +34,11 @@ def __init__( @SafeSlot() def run(self): try: - if self.action in ["add", "update", "remove"]: + if self.action == "set": + self._process( + {"action": self.action, "config": self.config, "wait_for_response": False} + ) + elif self.action in ["add", "update", "remove"]: if (dev_name := self.device or self.config.get("name")) is None: raise ValueError( "Must be updating a device or be supplied a name for a new device" @@ -57,6 +61,9 @@ def process_simple_action(self, dev_name: str, action: ConfigAction | None = Non "config": {dev_name: self.config}, "wait_for_response": False, } + self._process(req_args) + + def _process(self, req_args: dict): timeout = ( self.config_helper.suggested_timeout_s(self.config) if self.config is not None else 20 ) From 0cad33cbd3a46d43b787f60982f2af82c99c3d1f Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 15 Sep 2025 10:40:56 +0200 Subject: [PATCH 069/161] fix: check plugin exists before loading --- .../device_resource_backend.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py index 46cc2aaf8..1c385131e 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py @@ -12,7 +12,7 @@ from bec_lib.atlas_models import HashableDevice, HashableDeviceSet from bec_lib.bec_yaml_loader import yaml_load from bec_lib.logger import bec_logger -from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path, plugins_installed logger = bec_logger.logger @@ -81,17 +81,21 @@ def _devices_from_file(file: str, include_source: bool = True): class _ConfigFileBackend(DeviceResourceBackend): def __init__(self) -> None: - self._raw_device_set: set[ - HashableDevice - ] = self._get_config_from_backup_files() | self._get_configs_from_plugin_files( - Path(plugin_repo_path()) / plugin_package_name() / "device_configs/" - ) + self._raw_device_set: set[HashableDevice] = self._get_config_from_backup_files() + if plugins_installed() == 1: + self._raw_device_set.update( + self._get_configs_from_plugin_files( + Path(plugin_repo_path()) / plugin_package_name() / "device_configs/" + ) + ) self._device_groups = self._get_tag_groups() def _get_config_from_backup_files(self): dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs" files = sorted(glob("*.yaml", root_dir=dir)) last_n_files = files[-_N_RECOVERY_FILES:] + if len(last_n_files) == 0: + return set() return reduce( operator.or_, map( @@ -127,7 +131,7 @@ def allowed_sort_keys(self) -> set[str]: return {n for n, info in HashableDevice.model_fields.items() if info.annotation is str} def tags(self) -> set[str]: - return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set)) + return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set), {}) def tag_group(self, tag: str) -> set[HashableDevice]: return self.tag_groups[tag] From fb2dfff4f3f2cf975090f0aa05a1fc20dba6d8d5 Mon Sep 17 00:00:00 2001 From: David Perl Date: Wed, 10 Sep 2025 15:55:33 +0200 Subject: [PATCH 070/161] refactor: redo device tester --- .../device_manager_view.py | 2 +- .../device_resource_backend.py | 11 +- .../components/dm_ophyd_test.py | 173 +++++++++--------- 3 files changed, 94 insertions(+), 92 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index 5cdea7109..2f35b3d48 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -2,7 +2,7 @@ import os from functools import partial -from typing import TYPE_CHECKING, List +from typing import List import PySide6QtAds as QtAds import yaml diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py index 1c385131e..7da504e34 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py @@ -16,6 +16,8 @@ logger = bec_logger.logger +# use the last n recovery files +_N_RECOVERY_FILES = 3 _BASE_REPO_PATH = Path(os.path.dirname(bec_lib.__file__)) / "../.." @@ -75,10 +77,6 @@ def _devices_from_file(file: str, include_source: bool = True): ) -# use the last n recovery files -_N_RECOVERY_FILES = 3 - - class _ConfigFileBackend(DeviceResourceBackend): def __init__(self) -> None: self._raw_device_set: set[HashableDevice] = self._get_config_from_backup_files() @@ -94,14 +92,13 @@ def _get_config_from_backup_files(self): dir = _BASE_REPO_PATH / "logs/device_configs/recovery_configs" files = sorted(glob("*.yaml", root_dir=dir)) last_n_files = files[-_N_RECOVERY_FILES:] - if len(last_n_files) == 0: - return set() return reduce( operator.or_, map( partial(_devices_from_file, include_source=False), (str(dir / f) for f in last_n_files), ), + set(), ) def _get_configs_from_plugin_files(self, dir: Path): @@ -131,7 +128,7 @@ def allowed_sort_keys(self) -> set[str]: return {n for n, info in HashableDevice.model_fields.items() if info.annotation is str} def tags(self) -> set[str]: - return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set), {}) + return reduce(operator.or_, (dev.deviceTags for dev in self._raw_device_set), set()) def tag_group(self, tag: str) -> set[HashableDevice]: return self.tag_groups[tag] diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py index 2093eb6c5..1a4604592 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -4,20 +4,20 @@ import enum import re -import traceback +from collections import deque +from concurrent.futures import CancelledError, Future, ThreadPoolExecutor from html import escape -from typing import TYPE_CHECKING, Any +from threading import Event, RLock +from typing import Any, Iterable -import bec_lib from bec_lib.logger import bec_logger from bec_qthemes import material_icon -from ophyd import status -from qtpy import QtCore, QtGui, QtWidgets +from PySide6.QtCore import QThreadPool +from qtpy import QtCore, QtWidgets from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.utils.error_popups import SafeProperty, SafeSlot -from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget READY_TO_TEST = False @@ -34,11 +34,10 @@ ophyd_devices = None bec_server = None -if TYPE_CHECKING: # pragma no cover - try: - from ophyd_devices.utils.static_device_test import StaticDeviceTest - except ImportError: - StaticDeviceTest = None +try: + from ophyd_devices.utils.static_device_test import StaticDeviceTest +except ImportError: + StaticDeviceTest = None class ValidationStatus(int, enum.Enum): @@ -56,49 +55,77 @@ class DeviceValidationResult(QtCore.QObject): device_validated = QtCore.Signal(str, bool, str) -class DeviceValidationRunnable(QtCore.QRunnable): - """Runnable for validating a device configuration.""" - - def __init__( - self, - device_name: str, - config: dict, - static_device_test: StaticDeviceTest | None, - connect: bool = False, - ): - """ - Initialize the device validation runnable. - - Args: - device_name (str): The name of the device to validate. - config (dict): The configuration dictionary for the device. - static_device_test (StaticDeviceTest): The static device test instance. - connect (bool, optional): Whether to connect to the device. Defaults to False. - """ +class DeviceTester(QtCore.QRunnable): + def __init__(self, config: dict) -> None: super().__init__() - self.device_name = device_name - self.config = config - self._connect = connect - self._static_device_test = static_device_test self.signals = DeviceValidationResult() + self.shutdown_event = Event() + + self._config = config + + self._max_threads = 4 + self._pending_event = Event() + self._lock = RLock() + self._test_executor = ThreadPoolExecutor(self._max_threads, "device_manager_tester") + + self._pending_queue: deque[tuple[str, dict]] = deque([]) + self._active: set[str] = set() + + QtWidgets.QApplication.instance().aboutToQuit.connect(lambda: self.shutdown_event.set()) def run(self): - """Run method for device validation.""" - if self._static_device_test is None: - logger.error( - f"Ophyd devices or bec_server not available, cannot run validation for device {self.device_name}." - ) + if StaticDeviceTest is None: + logger.error("Ophyd devices or bec_server not available, cannot run validation.") return + while not self.shutdown_event.is_set(): + self._pending_event.wait(timeout=0.5) # check if shutting down every 0.5s + if len(self._active) >= self._max_threads: + self._pending_event.clear() # it will be set again on removing something from active + continue + with self._lock: + if len(self._pending_queue) > 0: + item, cfg = self._pending_queue.pop() + self._active.add(item) + fut = self._test_executor.submit(self._run_test, item, {item: cfg}) + fut.__dict__["__device_name"] = item + fut.add_done_callback(self._done_cb) + self._safe_check_and_clear() + + self._cleanup() + + def submit(self, devices: Iterable[tuple[str, dict]]): + with self._lock: + self._pending_queue.extend(devices) + self._pending_event.set() + + @staticmethod + def _run_test(name: str, config: dict) -> tuple[str, bool, str]: + tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None + results = tester.run_with_list_output(connect=False) + return name, results[0].success, results[0].message + + def _safe_check_and_clear(self): + with self._lock: + if len(self._pending_queue) == 0: + self._pending_event.clear() + + def _safe_remove_from_active(self, name: str): + with self._lock: + self._active.remove(name) + self._pending_event.set() # check again once a completed task is removed + + def _done_cb(self, future: Future): try: - self._static_device_test.config = {self.device_name: self.config} - results = self._static_device_test.run_with_list_output(connect=self._connect) - success = results[0].success - msg = results[0].message - self.signals.device_validated.emit(self.device_name, success, msg) - except Exception: - content = traceback.format_exc() - logger.error(f"Validation failed for device {self.device_name}. Exception: {content}") - self.signals.device_validated.emit(self.device_name, False, content) + name, success, message = future.result() + except CancelledError: + return + except Exception as e: + name, success, message = future.__dict__["__device_name"], False, str(e) + finally: + self._safe_remove_from_active(future.__dict__["__device_name"]) + self.signals.device_validated.emit(name, success, message) + + def _cleanup(self): ... class ValidationListItem(QtWidgets.QWidget): @@ -177,13 +204,13 @@ def __init__(self, parent=None, client=None): super().__init__(parent=parent, client=client) if not READY_TO_TEST: self.setDisabled(True) - self.static_device_test = None + self.tester = None else: - from ophyd_devices.utils.static_device_test import StaticDeviceTest - - self.static_device_test = StaticDeviceTest(config_dict={}) + self.tester = DeviceTester({}) + self.tester.signals.device_validated.connect(self._on_device_validated) + QThreadPool.globalInstance().start(self.tester) self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} - self._thread_pool = QtCore.QThreadPool.globalInstance() + self._thread_pool = QtCore.QThreadPool(maxThreadCount=1) self._main_layout = QtWidgets.QVBoxLayout(self) self._main_layout.setContentsMargins(0, 0, 0, 0) @@ -223,7 +250,9 @@ def change_device_configs(self, device_configs: list[dict[str, Any]], added: boo if added: if name in self._device_list_items: continue - self._add_device(name, cfg) + if self.tester: + self._add_device(name, cfg) + self.tester.submit([(name, cfg)]) continue if name not in self._device_list_items: continue @@ -238,7 +267,6 @@ def _add_device(self, name, cfg): self._list_widget.addItem(item) self._list_widget.setItemWidget(item, widget) self._device_list_items[name] = item - self._run_device_validation(widget) def _remove_list_item(self, device_name: str): """Remove a device from the list.""" @@ -254,34 +282,6 @@ def _remove_list_item(self, device_name: str): row = self._list_widget.row(item) self._list_widget.takeItem(row) - def _run_device_validation(self, widget: ValidationListItem): - """ - Run the device validation in a separate thread. - - Args: - widget (ValidationListItem): The widget to validate. - """ - if not READY_TO_TEST: - logger.error("Ophyd devices or bec_server not available, cannot run validation.") - return - if ( - widget.device_name in self.client.device_manager.devices - ): # TODO and config has to be exact the same.. - self._on_device_validated( - widget.device_name, - ValidationStatus.VALID, - f"Device {widget.device_name} is already in active config", - ) - return - runnable = DeviceValidationRunnable( - device_name=widget.device_name, - config=widget.device_config, - static_device_test=self.static_device_test, - connect=False, - ) - runnable.signals.device_validated.connect(self._on_device_validated) - self._thread_pool.start(runnable) - @SafeSlot(str, bool, str) def _on_device_validated(self, device_name: str, success: bool, message: str): """Handle the device validation result. @@ -391,6 +391,11 @@ def remove_device(self, device_name: str): if item: self._list_widget.removeItemWidget(item) + def cleanup(self): + if self.tester: + self.tester.shutdown_event.set() + return super().cleanup() + if __name__ == "__main__": import sys From 421e6d38e4f8eddf174c5ac494443e9f92fa1104 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 15 Sep 2025 11:30:40 +0200 Subject: [PATCH 071/161] style: imports --- .../available_device_resources_ui.py | 5 +---- .../device_manager/components/device_table_view.py | 14 ++++++++++---- .../device_manager/components/dm_ophyd_test.py | 4 +--- .../device_item/device_config_dialog.py | 5 +++-- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py index d136cbc05..7522fda85 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -1,11 +1,7 @@ from __future__ import annotations import itertools -from functools import partial -from typing import Generator -from bec_qthemes import material_icon -from PySide6.QtWidgets import QListWidgetItem, QWidget from qtpy.QtCore import QMetaObject, Qt from qtpy.QtWidgets import ( QAbstractItemView, @@ -15,6 +11,7 @@ QLineEdit, QListView, QListWidget, + QListWidgetItem, QVBoxLayout, ) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 221c835b6..130131f4f 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -12,11 +12,17 @@ from bec_lib.logger import bec_logger from bec_qthemes import material_icon -from PySide6.QtCore import QPoint, QRect, QSize -from PySide6.QtWidgets import QStyle, QStyleOption, QStyleOptionViewItem, QWidget from qtpy import QtCore, QtGui, QtWidgets -from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer -from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox +from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QPoint, QRect, QSize, Qt, QTimer +from qtpy.QtWidgets import ( + QAbstractItemView, + QHeaderView, + QMessageBox, + QStyle, + QStyleOption, + QStyleOptionViewItem, + QWidget, +) from thefuzz import fuzz from bec_widgets.utils.bec_signal_proxy import BECSignalProxy diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py index 1a4604592..a8e4ee9aa 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -12,7 +12,6 @@ from bec_lib.logger import bec_logger from bec_qthemes import material_icon -from PySide6.QtCore import QThreadPool from qtpy import QtCore, QtWidgets from bec_widgets.utils.bec_widget import BECWidget @@ -90,7 +89,6 @@ def run(self): fut.__dict__["__device_name"] = item fut.add_done_callback(self._done_cb) self._safe_check_and_clear() - self._cleanup() def submit(self, devices: Iterable[tuple[str, dict]]): @@ -208,7 +206,7 @@ def __init__(self, parent=None, client=None): else: self.tester = DeviceTester({}) self.tester.signals.device_validated.connect(self._on_device_validated) - QThreadPool.globalInstance().start(self.tester) + QtCore.QThreadPool.globalInstance().start(self.tester) self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} self._thread_pool = QtCore.QThreadPool(maxThreadCount=1) 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 927dda1d0..3cffb13b8 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 @@ -1,17 +1,18 @@ from ast import literal_eval -from typing import Any, Literal +from typing import Literal from bec_lib.atlas_models import Device as DeviceConfigModel from bec_lib.config_helper import CONF as DEVICE_CONF_KEYS from bec_lib.config_helper import ConfigHelper from bec_lib.logger import bec_logger from pydantic import BaseModel, field_validator -from PySide6.QtWidgets import QComboBox, QHBoxLayout from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore from qtpy.QtWidgets import ( QApplication, + QComboBox, QDialog, QDialogButtonBox, + QHBoxLayout, QLabel, QStackedLayout, QVBoxLayout, From ca9007a6e2a7254d84c61c2d1cbbdf34f234164f Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 15 Sep 2025 14:10:25 +0200 Subject: [PATCH 072/161] fix: tests --- bec_widgets/cli/client.py | 62 +++++++++++++++++++ .../device_resource_backend.py | 2 +- tests/unit_tests/test_device_browser.py | 2 +- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 41b9f0154..1d3a8a334 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -236,6 +236,26 @@ def selected_device(self) -> "str | None": """ +class AvailableDeviceResources(RPCBase): + @rpc_call + 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 BECDock(RPCBase): @property @rpc_call @@ -1100,6 +1120,48 @@ def dap_oversample(self): """ +class DMConfigView(RPCBase): + @rpc_call + 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 DMOphydTest(RPCBase): + """Widget to test device configurations using ophyd devices.""" + + @rpc_call + 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 DapComboBox(RPCBase): """The DAPComboBox widget is an extension to the QComboBox with all avaialble DAP model from BEC.""" diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py index 7da504e34..145d21109 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py @@ -103,7 +103,7 @@ def _get_config_from_backup_files(self): def _get_configs_from_plugin_files(self, dir: Path): files = glob("*.yaml", root_dir=dir, recursive=True) - return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files))) + return reduce(operator.or_, map(_devices_from_file, (str(dir / f) for f in files)), set()) def _get_tag_groups(self) -> dict[str, set[HashableDevice]]: return { diff --git a/tests/unit_tests/test_device_browser.py b/tests/unit_tests/test_device_browser.py index 2ccb25856..7c36594ee 100644 --- a/tests/unit_tests/test_device_browser.py +++ b/tests/unit_tests/test_device_browser.py @@ -100,7 +100,7 @@ def test_device_item_expansion(device_browser, qtbot): form = tab_widget.widget(0).layout().itemAt(0).widget() assert widget.expanded assert (name_field := form.widget_dict.get("name")) is not None - qtbot.waitUntil(lambda: name_field.getValue() == "samx", timeout=500) + qtbot.waitUntil(lambda: name_field.getValue() == "aptrx", timeout=500) qtbot.mouseClick(widget._expansion_button, Qt.MouseButton.LeftButton) assert not widget.expanded From d8900a579f04d82c02a352a958aa18f0936506de Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 16 Sep 2025 14:21:10 +0200 Subject: [PATCH 073/161] fix: preset classes for config dialog --- .../device_item/device_config_dialog.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) 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 3cffb13b8..f28d5e2c6 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 @@ -154,20 +154,30 @@ def accept(self): return super().accept() -class EpicsDeviceConfig(BaseModel): +class EpicsMotorConfig(BaseModel): prefix: str +class EpicsSignalROConfig(BaseModel): + read_pv: str + + +class EpicsSignalConfig(BaseModel): + read_pv: str + write_pv: str | None = None + + class PresetClassDeviceConfigDialog(DeviceConfigDialog): def __init__(self, *, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self._create_selection_box() - self._selection_box.currentTextChanged.connect(self._replace_form) self._device_models = { + "EpicsMotor": (EpicsMotorConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}), + "EpicsSignalRO": (EpicsSignalROConfig, {"deviceClass": ("ophyd.EpicsSignalRO", False)}), + "EpicsSignal": (EpicsSignalConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}), "Custom": (None, {}), - "EpicsMotor": (EpicsDeviceConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}), - "EpicsSignal": (EpicsDeviceConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}), } + self._create_selection_box() + self._selection_box.currentTextChanged.connect(self._replace_form) def _apply_constraints(self, constraints: dict[str, tuple[DynamicFormItemType, bool]]): for field_name, (value, editable) in constraints.items(): @@ -190,7 +200,7 @@ def _replace_form(self, deviceconfig_cls_key): def _create_selection_box(self): layout = QHBoxLayout() self._selection_box = QComboBox() - self._selection_box.addItems(["Custom", "EpicsMotor", "EpicsSignal"]) + self._selection_box.addItems(list(self._device_models.keys())) layout.addWidget(QLabel("Choose a device class: ")) layout.addWidget(self._selection_box) self._layout.insertLayout(0, layout) From 12a454f76253f0b221d6dbfe8b71d28efabf7260 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 30 Sep 2025 08:18:16 +0200 Subject: [PATCH 074/161] refactor: cleanup --- .../device_manager_view.py | 9 +- .../device_manager_widget.py | 16 ++- .../components/device_table_view.py | 32 ++--- .../components/dm_config_view.py | 21 ++- .../components/dm_docstring_view.py | 122 ++++++++-------- .../components/dm_ophyd_test.py | 135 +++++++++--------- 6 files changed, 181 insertions(+), 154 deletions(-) diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/examples/device_manager_view/device_manager_view.py index 2f35b3d48..90fd49208 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_view.py +++ b/bec_widgets/examples/device_manager_view/device_manager_view.py @@ -96,6 +96,7 @@ def __init__(self, parent=None, *args, **kwargs): self._root_layout.setContentsMargins(0, 0, 0, 0) self._root_layout.setSpacing(0) self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") self._root_layout.addWidget(self.dock_manager) # Available Resources Widget @@ -277,7 +278,9 @@ def _add_table_actions(self) -> None: # Rerun validation rerun_validation = MaterialIconAction( - icon_name="checklist", parent=self, tooltip="Run device validation on selected devices" + icon_name="checklist", + parent=self, + tooltip="Run device validation with 'connect' on selected devices", ) rerun_validation.action.triggered.connect(self._rerun_validation_action) self.toolbar.components.add_safe("rerun_validation", rerun_validation) @@ -433,8 +436,8 @@ def _remove_device_action(self): @SafeSlot() def _rerun_validation_action(self): """Action for the 'rerun_validation' action to rerun validation on selected devices.""" - # Implement the logic to rerun validation on selected devices - reply = self._coming_soon() + configs = self.device_table_view.table.selected_configs() + self.ophyd_test_view.change_device_configs(configs, True, True) ####### Default view has to be done with setting up splitters ######## def set_default_view(self, horizontal_weights: list, vertical_weights: list): diff --git a/bec_widgets/examples/device_manager_view/device_manager_widget.py b/bec_widgets/examples/device_manager_view/device_manager_widget.py index 9d4c9c80b..becd44b74 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_widget.py +++ b/bec_widgets/examples/device_manager_view/device_manager_widget.py @@ -100,10 +100,24 @@ def _load_config_clicked(self): from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + from bec_widgets.utils.colors import apply_theme + + apply_theme("light") + + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) device_manager = DeviceManagerWidget() # config = device_manager.client.device_manager._get_redis_device_config() # device_manager.device_table_view.set_device_config(config) - device_manager.show() + layout.addWidget(device_manager) + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + dark_mode_button = DarkModeButton() + layout.addWidget(dark_mode_button) + widget.show() device_manager.setWindowTitle("Device Manager View") device_manager.resize(1600, 1200) # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 130131f4f..3dc804ca5 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -7,22 +7,13 @@ from contextlib import contextmanager from functools import partial from typing import TYPE_CHECKING, Any, Iterable, List -from unittest.mock import MagicMock, patch from uuid import uuid4 from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtGui, QtWidgets -from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QPoint, QRect, QSize, Qt, QTimer -from qtpy.QtWidgets import ( - QAbstractItemView, - QHeaderView, - QMessageBox, - QStyle, - QStyleOption, - QStyleOptionViewItem, - QWidget, -) +from qtpy.QtCore import QModelIndex, QPersistentModelIndex, Qt, QTimer +from qtpy.QtWidgets import QAbstractItemView, QHeaderView, QMessageBox from thefuzz import fuzz from bec_widgets.utils.bec_signal_proxy import BECSignalProxy @@ -91,8 +82,8 @@ class CenterCheckBoxDelegate(CustomDisplayDelegate): def __init__(self, parent=None, colors=None): super().__init__(parent) - self._colors: AccentColors = colors if colors else get_accent_colors() # type: ignore - _icon = partial(material_icon, size=(16, 16), color=self._colors.default, filled=True) + colors: AccentColors = colors if colors else get_accent_colors() # type: ignore + _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) self._icon_checked = _icon("check_box") self._icon_unchecked = _icon("check_box_outline_blank") @@ -122,12 +113,12 @@ class DeviceValidatedDelegate(CustomDisplayDelegate): def __init__(self, parent=None, colors=None): super().__init__(parent) - self._colors = colors if colors else get_accent_colors() + colors = colors if colors else get_accent_colors() _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) self._icons = { - ValidationStatus.PENDING: _icon(color=self._colors.default), - ValidationStatus.VALID: _icon(color=self._colors.success), - ValidationStatus.FAILED: _icon(color=self._colors.emergency), + ValidationStatus.PENDING: _icon(color=colors.default), + ValidationStatus.VALID: _icon(color=colors.success), + ValidationStatus.FAILED: _icon(color=colors.emergency), } def apply_theme(self, theme: str | None = None): @@ -750,6 +741,7 @@ def apply_theme(self, theme: str | None = None): ########### Slot API ################# ###################################### + # TODO RESIZING IS not working as it should be !! @SafeSlot() def _on_table_resized(self, *args): """Handle changes to the table column resizing.""" @@ -870,8 +862,8 @@ def _button_clicked(): button.clicked.connect(_button_clicked) # pylint: disable=protected-access config = window.client.device_manager._get_redis_device_config() - names = [cfg.pop("name") for cfg in config] - config_dict = {name: cfg for name, cfg in zip(names, config)} - window.set_device_config(config_dict) + # names = [cfg.pop("name") for cfg in config] + # config_dict = {name: cfg for name, cfg in zip(names, config)} + window.set_device_config(config) widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py index f13f9a760..245080f32 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_config_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -9,7 +9,6 @@ from qtpy import QtCore, QtWidgets from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors, get_theme_palette from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget @@ -78,6 +77,24 @@ def on_select_config(self, device: list[dict]): from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) config_view = DMConfigView() - config_view.show() + layout.addWidget(config_view) + combo_box = QtWidgets.QComboBox() + config = config_view.client.device_manager._get_redis_device_config() + combo_box.addItems([""] + [str(v) for v, item in enumerate(config)]) + + def on_select(text): + if text == "": + config_view.on_select_config([]) + else: + config_view.on_select_config([config[int(text)]]) + + combo_box.currentTextChanged.connect(on_select) + layout.addWidget(combo_box) + widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py index a0f135b43..cd617d8fd 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -4,6 +4,7 @@ import inspect import re +import textwrap import traceback from bec_lib.logger import bec_logger @@ -26,70 +27,58 @@ ophyd = None -class DocstringView(QtWidgets.QTextEdit): - def __init__(self, parent: QtWidgets.QWidget | None = None): - super().__init__(parent) - self.setReadOnly(True) - self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) - if not READY_TO_VIEW: - self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.") - self.setEnabled(False) - return - - def _format_docstring(self, doc: str | None) -> str: - if not doc: - return "No docstring available." - - # Escape HTML - doc = doc.replace("&", "&").replace("<", "<").replace(">", ">") +def docstring_to_markdown(obj) -> str: + """ + Convert a Python docstring to Markdown suitable for QTextEdit.setMarkdown. + """ + raw = inspect.getdoc(obj) or "*No docstring available.*" - # Remove leading/trailing blank lines from the entire docstring - lines = [line.rstrip() for line in doc.splitlines()] - while lines and lines[0].strip() == "": - lines.pop(0) - while lines and lines[-1].strip() == "": - lines.pop() - doc = "\n".join(lines) + # Dedent and normalize newlines + text = textwrap.dedent(raw).strip() - # Improved regex: match section header + all following indented lines - section_regex = re.compile( - r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b(?:\n([ \t]+.*))*", - re.MULTILINE, - ) + md = "" + if hasattr(obj, "__name__"): + md += f"# {obj.__name__}\n\n" - def strip_section(match: re.Match) -> str: - # Capture all lines in the match - block = match.group(0) - lines = block.splitlines() - # Remove leading/trailing empty lines within the section - lines = [line for line in lines if line.strip() != ""] - return "\n".join(lines) + # Highlight section headers for Markdown + headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"] + for h in headers: + doc = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text) - doc = section_regex.sub(strip_section, doc) + # Preserve code blocks (4+ space indented lines) + def fence_code(match: re.Match) -> str: + block = re.sub(r"^ {4}", "", match.group(0), flags=re.M) + return f"```\n{block}\n```" - # Highlight section titles - doc = re.sub( - r"(?m)^(Parameters|Args|Returns|Examples|Attributes|Raises)\b", r"\1", doc - ) + doc = re.sub(r"(?m)(^ {4,}.*(\n {4,}.*)*)", fence_code, text) - # Convert indented blocks to
 and strip leading/trailing newlines
-        def pre_block(match: re.Match) -> str:
-            text = match.group(0).strip("\n")
-            return f"
{text}
" + # Preserve normal line breaks for Markdown + lines = doc.splitlines() + processed_lines = [] + for line in lines: + if line.strip() == "": + processed_lines.append("") + else: + processed_lines.append(line + " ") + doc = "\n".join(processed_lines) - doc = re.sub(r"(?m)(?:\n[ \t]+.*)+", pre_block, doc) + md += doc + return md - # Replace remaining newlines with
and collapse multiple
- doc = doc.replace("\n", "
") - doc = re.sub(r"(
)+", r"
", doc) - doc = doc.strip("
") - return f"
{doc}
" +class DocstringView(QtWidgets.QTextEdit): + def __init__(self, parent: QtWidgets.QWidget | None = None): + super().__init__(parent) + self.setReadOnly(True) + self.setFocusPolicy(QtCore.Qt.FocusPolicy.NoFocus) + if not READY_TO_VIEW: + self._set_text("Ophyd or ophyd_devices not installed, cannot show docstrings.") + self.setEnabled(False) + return def _set_text(self, text: str): self.setReadOnly(False) self.setMarkdown(text) - # self.setHtml(self._format_docstring(text)) self.setReadOnly(True) @SafeSlot(list) @@ -102,17 +91,15 @@ def on_select_config(self, device: list[dict]): @SafeSlot(str) def set_device_class(self, device_class_str: str) -> None: - docstring = "" if not READY_TO_VIEW: return try: module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd]) - docstring = inspect.getdoc(module_cls) - self._set_text(docstring or "No docstring available.") + markdown = docstring_to_markdown(module_cls) + self._set_text(markdown) except Exception: - content = traceback.format_exc() - logger.error(f"Error retrieving docstring for {device_class_str}: {content}") - self._set_text(f"Error retrieving docstring for {device_class_str}") + logger.exception("Error retrieving docstring") + self._set_text(f"*Error retrieving docstring for `{device_class_str}`*") if __name__ == "__main__": @@ -121,7 +108,26 @@ def set_device_class(self, device_class_str: str) -> None: from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + config_view = DocstringView() config_view.set_device_class("ophyd_devices.sim.sim_camera.SimCamera") - config_view.show() + layout.addWidget(config_view) + combo = QtWidgets.QComboBox() + combo.addItems( + [ + "", + "ophyd_devices.sim.sim_camera.SimCamera", + "ophyd.EpicsSignalWithRBV", + "ophyd.EpicsMotor", + "csaxs_bec.devices.epics.mcs_card.mcs_card_csaxs.MCSCardCSAXS", + ] + ) + combo.currentTextChanged.connect(config_view.set_device_class) + layout.addWidget(combo) + widget.show() sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py index a8e4ee9aa..fe40cfaa7 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -83,23 +83,23 @@ def run(self): continue with self._lock: if len(self._pending_queue) > 0: - item, cfg = self._pending_queue.pop() + item, cfg, connect = self._pending_queue.pop() self._active.add(item) - fut = self._test_executor.submit(self._run_test, item, {item: cfg}) + fut = self._test_executor.submit(self._run_test, item, {item: cfg}, connect) fut.__dict__["__device_name"] = item fut.add_done_callback(self._done_cb) self._safe_check_and_clear() self._cleanup() - def submit(self, devices: Iterable[tuple[str, dict]]): + def submit(self, devices: Iterable[tuple[str, dict, bool]]): with self._lock: self._pending_queue.extend(devices) self._pending_event.set() @staticmethod - def _run_test(name: str, config: dict) -> tuple[str, bool, str]: + def _run_test(name: str, config: dict, connect: bool) -> tuple[str, bool, str]: tester = StaticDeviceTest(config_dict=config) # type: ignore # we exit early if it is None - results = tester.run_with_list_output(connect=False) + results = tester.run_with_list_output(connect=connect) return name, results[0].success, results[0].message def _safe_check_and_clear(self): @@ -164,7 +164,6 @@ def _setup_ui(self): def _start_spinner(self): """Start the spinner animation.""" self._spinner.start() - QtWidgets.QApplication.processEvents() def _stop_spinner(self): """Stop the spinner animation.""" @@ -197,6 +196,8 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget): # Signal to emit the validation status of a device device_validated = QtCore.Signal(str, int) + # validation_msg in markdown format + validation_msg_md = QtCore.Signal(str) def __init__(self, parent=None, client=None): super().__init__(parent=parent, client=client) @@ -208,18 +209,18 @@ def __init__(self, parent=None, client=None): self.tester.signals.device_validated.connect(self._on_device_validated) QtCore.QThreadPool.globalInstance().start(self.tester) self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} - self._thread_pool = QtCore.QThreadPool(maxThreadCount=1) + # TODO Consider using the thread pool from BECConnector instead of fetching the global instance! + self._thread_pool = QtCore.QThreadPool.globalInstance() self._main_layout = QtWidgets.QVBoxLayout(self) self._main_layout.setContentsMargins(0, 0, 0, 0) - self._main_layout.setSpacing(4) + self._main_layout.setSpacing(0) # We add a splitter between the list and the text box self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical) self._main_layout.addWidget(self.splitter) self._setup_list_ui() - self._setup_textbox_ui() def _setup_list_ui(self): """Setup the list UI.""" @@ -229,15 +230,11 @@ def _setup_list_ui(self): # Connect signals self._list_widget.currentItemChanged.connect(self._on_current_item_changed) - def _setup_textbox_ui(self): - """Setup the text box UI.""" - self._text_box = QtWidgets.QTextEdit(self) - self._text_box.setReadOnly(True) - self._text_box.setFocusPolicy(QtCore.Qt.NoFocus) - self.splitter.addWidget(self._text_box) - - @SafeSlot(dict) - def change_device_configs(self, device_configs: list[dict[str, Any]], added: bool) -> None: + @SafeSlot(list, bool) + @SafeSlot(list, bool, bool) + def change_device_configs( + self, device_configs: list[dict[str, Any]], added: bool, connect: bool = False + ) -> None: """Receive an update with device configs. Args: @@ -250,7 +247,7 @@ def change_device_configs(self, device_configs: list[dict[str, Any]], added: boo continue if self.tester: self._add_device(name, cfg) - self.tester.submit([(name, cfg)]) + self.tester.submit([(name, cfg, connect)]) continue if name not in self._device_list_items: continue @@ -314,62 +311,39 @@ def _on_current_item_changed( widget: ValidationListItem = self._list_widget.itemWidget(current) if widget: try: - formatted_html = self._format_validation_message(widget.validation_msg) - self._text_box.setHtml(formatted_html) + formatted_md = self._format_markdown_text(widget.device_name, widget.validation_msg) + self.validation_msg_md.emit(formatted_md) except Exception as e: - logger.error(f"Error formatting validation message: {e}") - self._text_box.setPlainText(widget.validation_msg) + logger.error( + f"##Error formatting validation message for device {widget.device_name}:\n{e}" + ) + self.validation_msg_md.emit(widget.validation_msg) + else: + self.validation_msg_md.emit("") - def _format_validation_message(self, raw_msg: str) -> str: + def _format_markdown_text(self, device_name: str, raw_msg: str) -> str: """Simple HTML formatting for validation messages, wrapping text naturally.""" if not raw_msg.strip(): - return "Validation in progress..." + return f"### Validation in progress for {device_name}... \n\n" if raw_msg == "Validation in progress...": - return "Validation in progress..." - - raw_msg = escape(raw_msg) + return f"### Validation in progress for {device_name}... \n\n" - # Split into lines - lines = raw_msg.splitlines() - summary = lines[0] if lines else "Validation Result" - rest = "\n".join(lines[1:]).strip() + m = re.search(r"ERROR:\s*([^\s]+)\s+is not valid:\s*(.+?errors?)", raw_msg) + device, summary = m.group(1), m.group(2) + lines = [f"## Error for '{device}'", f"'{device}' is not valid: {summary}"] - # Split traceback / final ERROR - tb_match = re.search(r"(Traceback.*|ERROR:.*)$", rest, re.DOTALL | re.MULTILINE) - if tb_match: - main_text = rest[: tb_match.start()].strip() - error_detail = tb_match.group().strip() - else: - main_text = rest - error_detail = "" - - # Highlight field names in orange (simple regex for word: Field) - main_text_html = re.sub( - r"(\b\w+\b)(?=: Field required)", - r'\1', - main_text, - ) - # Wrap in div for monospace, allowing wrapping - main_text_html = ( - f'
{main_text_html}
' if main_text_html else "" + # Find each field block: \n\n Field required ... + field_pat = re.compile( + r"\n(?P\w+)\n\s+(?PField required.*?(?=\n\w+\n|$))", re.DOTALL ) - # Traceback / error in red - error_html = ( - f'
{error_detail}
' - if error_detail - else "" - ) + for m in field_pat.finditer(raw_msg): + field = m.group("field") + rest = m.group("rest").rstrip() + lines.append(f"### {field}") + lines.append(rest) - # Summary at top, dark red - html = ( - f'
' - f'
{summary}
' - f"{main_text_html}" - f"{error_html}" - f"
" - ) - return html + return "\n".join(lines) def validation_running(self): return self._device_list_items != {} @@ -382,6 +356,7 @@ def clear_list(self): logger.error("Failed to wait for threads to finish. Removing items from the list.") self._device_list_items.clear() self._list_widget.clear() + self.validation_msg_md.emit("") def remove_device(self, device_name: str): """Remove a device from the list.""" @@ -404,12 +379,32 @@ def cleanup(self): from qtpy.QtWidgets import QApplication app = QApplication(sys.argv) + wid = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(wid) + wid.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) device_manager_ophyd_test = DMOphydTest() - config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml" - cfg = yaml_load(config_path) - cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) - device_manager_ophyd_test.add_device_configs(cfg) - device_manager_ophyd_test.show() + try: + config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml" + config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] + except Exception as e: + logger.error(f"Error loading config: {e}") + import os + + import bec_lib + + config_path = os.path.join(os.path.dirname(bec_lib.__file__), "configs", "demo_config.yaml") + config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] + + config.append({"name": "non_existing_device", "type": "NonExistingDevice"}) + device_manager_ophyd_test.change_device_configs(config, True, True) + layout.addWidget(device_manager_ophyd_test) device_manager_ophyd_test.setWindowTitle("Device Manager Ophyd Test") device_manager_ophyd_test.resize(800, 600) + text_box = QtWidgets.QTextEdit() + text_box.setReadOnly(True) + layout.addWidget(text_box) + device_manager_ophyd_test.validation_msg_md.connect(text_box.setMarkdown) + wid.show() sys.exit(app.exec_()) From 1b7f9a332307ce3ff1c3ce7edfcc2436f847479d Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 30 Sep 2025 08:18:39 +0200 Subject: [PATCH 075/161] fix: mark processEvents for checks --- .../device_browser/device_item/device_config_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f28d5e2c6..ceaea99ad 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 @@ -324,12 +324,12 @@ def update_error(self, e: Exception): def _start_waiting_display(self): self._overlay_widget.setVisible(True) self._spinner.start() - QApplication.processEvents() + QApplication.processEvents() # TODO check if this kills performance and scheduling! def _stop_waiting_display(self): self._overlay_widget.setVisible(False) self._spinner.stop() - QApplication.processEvents() + QApplication.processEvents() # TODO check if this kills performance and scheduling! def main(): # pragma: no cover From 016e1af79e2d2419d7b6dfd8c8bfa295868275c3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 2 Oct 2025 12:33:32 +0200 Subject: [PATCH 076/161] feat(actions): actions can be created with label text with beside or under alignment --- bec_widgets/utils/toolbars/actions.py | 63 +++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 4278877b1..9cfc88ac3 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -33,6 +33,26 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__) +def create_action_with_text(toolbar_action, toolbar: QToolBar): + """ + Helper function to create a toolbar button with text beside or under the icon. + + Args: + toolbar_action(ToolBarAction): The toolbar action to create the button for. + toolbar(ModularToolBar): The toolbar to add the button to. + """ + + btn = QToolButton(parent=toolbar) + btn.setDefaultAction(toolbar_action.action) + btn.setAutoRaise(True) + if toolbar_action.text_position == "under": + btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + else: + btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + btn.setText(toolbar_action.label_text) + toolbar.addWidget(btn) + + class NoCheckDelegate(QStyledItemDelegate): """To reduce space in combo boxes by removing the checkmark.""" @@ -114,15 +134,39 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): class QtIconAction(ToolBarAction): - def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None): + def __init__( + self, + standard_icon, + tooltip=None, + checkable=False, + label_text: str | None = None, + text_position: Literal["beside", "under"] | None = None, + parent=None, + ): + """ + Action with a standard Qt icon for the toolbar. + + Args: + standard_icon: The standard icon from QStyle. + tooltip(str, optional): The tooltip for the action. Defaults to None. + checkable(bool, optional): Whether the action is checkable. Defaults to False. + label_text(str | None, optional): Optional label text to display beside or under the icon. + text_position(Literal["beside", "under"] | None, optional): Position of text relative to icon. + parent(QWidget or None, optional): Parent widget for the underlying QAction. + """ super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) self.standard_icon = standard_icon self.icon = QApplication.style().standardIcon(standard_icon) self.action = QAction(icon=self.icon, text=self.tooltip, parent=parent) self.action.setCheckable(self.checkable) + self.label_text = label_text + self.text_position = text_position def add_to_toolbar(self, toolbar, target): - toolbar.addAction(self.action) + if self.label_text is not None: + create_action_with_text(toolbar_action=self, toolbar=toolbar) + else: + toolbar.addAction(self.action) def get_icon(self): return self.icon @@ -139,6 +183,8 @@ class MaterialIconAction(ToolBarAction): filled (bool, optional): Whether the icon is filled. Defaults to False. color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon. Defaults to None. + label_text (str | None, optional): Optional label text to display beside or under the icon. + text_position (Literal["beside", "under"] | None, optional): Position of text relative to icon. parent (QWidget or None, optional): Parent widget for the underlying QAction. """ @@ -149,12 +195,20 @@ def __init__( checkable: bool = False, filled: bool = False, color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, + label_text: str | None = None, + text_position: Literal["beside", "under"] | None = None, parent=None, ): + """ + MaterialIconAction for toolbar: if label_text and text_position are provided, show text beside or under icon. + This enables per-action icon text without breaking the existing API. + """ super().__init__(icon_path=None, tooltip=tooltip, checkable=checkable) self.icon_name = icon_name self.filled = filled self.color = color + self.label_text = label_text + self.text_position = text_position # Generate the icon using the material_icon helper self.icon = material_icon( self.icon_name, @@ -178,7 +232,10 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar(QToolBar): The toolbar to add the action to. target(QWidget): The target widget for the action. """ - toolbar.addAction(self.action) + if self.label_text is not None: + create_action_with_text(toolbar_action=self, toolbar=toolbar) + else: + toolbar.addAction(self.action) def get_icon(self): """ From 99007d8cf56de3a102f462f5b5c43d38b473cab9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 30 Sep 2025 15:07:02 +0200 Subject: [PATCH 077/161] feat(main_app): device manager implemented into main app --- bec_widgets/applications/main_app.py | 9 +++++++++ .../views}/device_manager_view/__init__.py | 0 .../views}/device_manager_view/device_manager_view.py | 0 .../views}/device_manager_view/device_manager_widget.py | 3 ++- 4 files changed, 11 insertions(+), 1 deletion(-) rename bec_widgets/{examples => applications/views}/device_manager_view/__init__.py (100%) rename bec_widgets/{examples => applications/views}/device_manager_view/device_manager_view.py (100%) rename bec_widgets/{examples => applications/views}/device_manager_view/device_manager_widget.py (97%) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 791f07519..61247b83a 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -3,6 +3,7 @@ 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.device_manager_view.device_manager_view import DeviceManagerView 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 @@ -44,10 +45,18 @@ def __init__( def _add_views(self): self.add_section("BEC Applications", "bec_apps") self.ads = AdvancedDockArea(self) + self.device_manager = DeviceManagerView(self) self.add_view( icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" ) + self.add_view( + icon="display_settings", + title="Device Manager", + id="device_manager", + widget=self.device_manager, + mini_text="DM", + ) if self._show_examples: self.add_section("Examples", "examples") diff --git a/bec_widgets/examples/device_manager_view/__init__.py b/bec_widgets/applications/views/device_manager_view/__init__.py similarity index 100% rename from bec_widgets/examples/device_manager_view/__init__.py rename to bec_widgets/applications/views/device_manager_view/__init__.py diff --git a/bec_widgets/examples/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py similarity index 100% rename from bec_widgets/examples/device_manager_view/device_manager_view.py rename to bec_widgets/applications/views/device_manager_view/device_manager_view.py diff --git a/bec_widgets/examples/device_manager_view/device_manager_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py similarity index 97% rename from bec_widgets/examples/device_manager_view/device_manager_widget.py rename to bec_widgets/applications/views/device_manager_view/device_manager_widget.py index becd44b74..fec34671f 100644 --- a/bec_widgets/examples/device_manager_view/device_manager_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py @@ -9,13 +9,14 @@ from bec_qthemes import material_icon from qtpy import QtCore, QtWidgets -from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeSlot logger = bec_logger.logger +#TODO what is this for??? class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): def __init__(self, parent=None, client=None): From 657648c833572ea8784a86ee1b1980bb38c2576f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 30 Sep 2025 15:07:32 +0200 Subject: [PATCH 078/161] refactor(examples): wrong main app removed --- bec_widgets/applications/main_app.py | 6 +- .../device_manager_view.py | 8 ++- bec_widgets/examples/bec_main_app/__init__.py | 0 .../examples/bec_main_app/bec_main_app.py | 67 ------------------- 4 files changed, 10 insertions(+), 71 deletions(-) delete mode 100644 bec_widgets/examples/bec_main_app/__init__.py delete mode 100644 bec_widgets/examples/bec_main_app/bec_main_app.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 61247b83a..1f90dd0eb 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -3,7 +3,9 @@ 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.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( + DeviceManagerWidget, +) 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 @@ -45,7 +47,7 @@ def __init__( def _add_views(self): self.add_section("BEC Applications", "bec_apps") self.ads = AdvancedDockArea(self) - self.device_manager = DeviceManagerView(self) + self.device_manager = DeviceManagerWidget(self) self.add_view( icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 90fd49208..210c71738 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -440,7 +440,9 @@ def _rerun_validation_action(self): self.ophyd_test_view.change_device_configs(configs, True, True) ####### Default view has to be done with setting up splitters ######## - def set_default_view(self, horizontal_weights: list, vertical_weights: list): + def set_default_view( + self, horizontal_weights: list, vertical_weights: list + ): # TODO separate logic for all ads based widgets """Apply initial weights to every horizontal and vertical splitter. Examples: @@ -463,7 +465,9 @@ def apply_all(): QTimer.singleShot(0, apply_all) - def set_stretch(self, *, horizontal=None, vertical=None): + def set_stretch( + self, *, horizontal=None, vertical=None + ): # TODO separate logic for all ads based widgets """Update splitter weights and re-apply to all splitters. Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict diff --git a/bec_widgets/examples/bec_main_app/__init__.py b/bec_widgets/examples/bec_main_app/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bec_widgets/examples/bec_main_app/bec_main_app.py b/bec_widgets/examples/bec_main_app/bec_main_app.py deleted file mode 100644 index 4c70c1981..000000000 --- a/bec_widgets/examples/bec_main_app/bec_main_app.py +++ /dev/null @@ -1,67 +0,0 @@ -from qtpy import QtCore, QtWidgets - -from bec_widgets.examples.device_manager_view.device_manager_view import DeviceManagerView -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea - - -class BECMainApp(QtWidgets.QWidget): - def __init__(self, parent=None): - super().__init__(parent) - - # Main layout - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Tab widget as central area - self.tabs = QtWidgets.QTabWidget(self) - self.tabs.setContentsMargins(0, 0, 0, 0) - self.tabs.setTabPosition(QtWidgets.QTabWidget.West) # Tabs on the left side - - layout.addWidget(self.tabs) - # Add DM - self._add_device_manager_view() - - # Add Plot area - self._add_ad_dockarea() - - # Adjust size of tab bar - # TODO not yet properly working, tabs a spread across the full length, to be checked! - tab_bar = self.tabs.tabBar() - tab_bar.setFixedWidth(tab_bar.sizeHint().width()) - - def _add_device_manager_view(self) -> None: - self.device_manager_view = DeviceManagerView(parent=self) - self.add_tab(self.device_manager_view, "Device Manager") - - def _add_ad_dockarea(self) -> None: - self.advanced_dock_area = AdvancedDockArea(parent=self) - self.add_tab(self.advanced_dock_area, "Plot Area") - - def add_tab(self, widget: QtWidgets.QWidget, title: str): - """Add a custom QWidget as a tab.""" - tab_container = QtWidgets.QWidget() - tab_layout = QtWidgets.QVBoxLayout(tab_container) - tab_layout.setContentsMargins(0, 0, 0, 0) - tab_layout.setSpacing(0) - - tab_layout.addWidget(widget) - self.tabs.addTab(tab_container, title) - - -if __name__ == "__main__": - import sys - - from bec_lib.bec_yaml_loader import yaml_load - from bec_qthemes import apply_theme - - app = QtWidgets.QApplication(sys.argv) - apply_theme("light") - win = BECMainApp() - config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" - cfg = yaml_load(config_path) - cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) - win.device_manager_view.device_table_view.set_device_config(cfg) - win.resize(1920, 1080) - win.show() - sys.exit(app.exec_()) From 718f4564ab7643230aeb564f9431119f533cff23 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 30 Sep 2025 15:14:21 +0200 Subject: [PATCH 079/161] fix(device_manager_view): removed custom styling for overlay --- .../views/device_manager_view/device_manager_widget.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py index fec34671f..8c24a9b95 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py @@ -16,7 +16,6 @@ logger = bec_logger.logger -#TODO what is this for??? class DeviceManagerWidget(BECWidget, QtWidgets.QWidget): def __init__(self, parent=None, client=None): @@ -38,9 +37,6 @@ def __init__(self, parent=None, client=None): self.stacked_layout.setCurrentWidget(self._overlay_widget) def _customize_overlay(self): - self._overlay_widget.setStyleSheet( - "background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0 #ffffff, stop:1 #e0e0e0);" - ) self._overlay_widget.setAutoFillBackground(True) self._overlay_layout = QtWidgets.QVBoxLayout() self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) @@ -89,13 +85,11 @@ def _load_config_from_file(self, file_path: str): def _load_config_clicked(self): """Handle click on 'Load Current Config' button.""" config = self.client.device_manager._get_redis_device_config() - config.append({"name": "wrong_device", "some_value": 1}) self.device_manager_view.device_table_view.set_device_config(config) - # self.device_manager_view.ophyd_test.on_device_config_update(config) self.stacked_layout.setCurrentWidget(self.device_manager_view) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys from qtpy.QtWidgets import QApplication From fcd7b3d64228655b28e2c96fe095609c7cb4d74d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 30 Sep 2025 15:53:22 +0200 Subject: [PATCH 080/161] perf(device_table_view): text wrapper delegate removed since it was not working correctly anyway --- .../components/device_table_view.py | 87 +++++++------------ 1 file changed, 32 insertions(+), 55 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 3dc804ca5..4a92e8416 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -133,39 +133,6 @@ def _do_custom_paint(self, painter, option, index, value): painter.drawPixmap(pix_rect.topLeft(), pixmap) -class WrappingTextDelegate(CustomDisplayDelegate): - """Custom delegate for wrapping text in table cells.""" - - def __init__(self, table: BECTableView, parent=None): - super().__init__(parent) - self._table = table - - def _do_custom_paint(self, painter, option, index, value): - painter.setClipRect(option.rect) - text_option = ( - Qt.TextFlag.TextWordWrap | Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop - ) - painter.drawText(option.rect.adjusted(4, 2, -5, -2), text_option, value) - - def sizeHint(self, option, index): - text = str(index.model().data(index, Qt.ItemDataRole.DisplayRole) or "") - column_width = self._table.columnWidth(index.column()) - 8 # -4 & 4 - - # Avoid pathological heights for too-narrow columns - min_width = option.fontMetrics.averageCharWidth() * 4 - if column_width < min_width: - fm = QtGui.QFontMetrics(option.font) - return QtCore.QSize(column_width, fm.height() + 4) - - doc = QtGui.QTextDocument() - doc.setDefaultFont(option.font) - doc.setTextWidth(column_width) - doc.setPlainText(text) - - layout_height = doc.documentLayout().documentSize().height() - return QtCore.QSize(column_width, int(layout_height) + 4) - - class DeviceTableModel(QtCore.QAbstractTableModel): """ Custom Device Table Model for managing device configurations. @@ -364,9 +331,10 @@ def remove_configs_by_name(self, names: Iterable[str]): def get_by_name(self, name: str) -> dict[str, Any] | None: for cfg in self._device_config: - if cfg.get(name) == name: + if cfg.get("name") == name: return cfg logger.warning(f"Device {name} does not exist in the model.") + return None @contextmanager def _remove_row(self, row: int): @@ -683,24 +651,31 @@ def _setup_table_view(self) -> None: # Delegates colors = get_accent_colors() self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors) - self.wrap_delegate = WrappingTextDelegate(self.table) self.tool_tip_delegate = DictToolTipDelegate(self.table) self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors) self.table.setItemDelegateForColumn(0, self.validated_delegate) # ValidationStatus self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn(4, self.wrap_delegate) # deviceTags + self.table.setItemDelegateForColumn( + 4, self.tool_tip_delegate + ) # deviceTags (was wrap_delegate) self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # enabled self.table.setItemDelegateForColumn(6, self.checkbox_delegate) # readOnly + # Disable wrapping, use eliding, and smooth scrolling + self.table.setWordWrap(False) + self.table.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight) + self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + # Column resize policies header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # ValidationStatus - header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # name - header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # deviceClass - header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # readoutPriority - header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # deviceTags + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # name + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # deviceClass + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) # readoutPriority + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # deviceTags: expand to fill header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # enabled header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # readOnly @@ -711,11 +686,7 @@ def _setup_table_view(self) -> None: # Ensure column widths stay fixed header.setMinimumSectionSize(25) header.setDefaultSectionSize(90) - - # Enable resizing of column - self._geometry_resize_proxy = BECSignalProxy( - header.geometriesChanged, rateLimit=10, slot=self._on_table_resized - ) + header.setStretchLastSection(False) # Selection behavior self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) @@ -724,7 +695,10 @@ def _setup_table_view(self) -> None: self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) self.table.horizontalHeader().setHighlightSections(False) - # Qtimer.singleShot(0, lambda: header.sectionResized.emit(0, 0, 0)) + # Connect model signals to autosize request + self._model.rowsInserted.connect(self._request_autosize_columns) + self._model.modelReset.connect(self._request_autosize_columns) + self._model.dataChanged.connect(self._request_autosize_columns) def remove_selected_rows(self): self.table.delete_selected() @@ -741,16 +715,19 @@ def apply_theme(self, theme: str | None = None): ########### Slot API ################# ###################################### - # TODO RESIZING IS not working as it should be !! + def _request_autosize_columns(self, *args): + if not hasattr(self, "_autosize_timer"): + self._autosize_timer = QtCore.QTimer(self) + self._autosize_timer.setSingleShot(True) + self._autosize_timer.timeout.connect(self._autosize_columns) + self._autosize_timer.start(0) + @SafeSlot() - def _on_table_resized(self, *args): - """Handle changes to the table column resizing.""" - option = QtWidgets.QStyleOptionViewItem() - model = self.table.model() - for row in range(model.rowCount()): - index = model.index(row, 4) - height = self.wrap_delegate.sizeHint(option, index).height() - self.table.setRowHeight(row, height) + def _autosize_columns(self): + if self._model.rowCount() == 0: + return + for col in (1, 2, 3): + self.table.resizeColumnToContents(col) @SafeSlot(str) def _handle_shared_selection_signal(self, uuid: str): From bd9db4f20e9b5f9333a74dc30a53135d38d4945c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 30 Sep 2025 17:17:02 +0200 Subject: [PATCH 081/161] fix(available_device_resources): top toolbar size fixed --- .../available_device_resources_ui.py | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py index 7522fda85..05701864a 100644 --- a/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -6,12 +6,13 @@ from qtpy.QtWidgets import ( QAbstractItemView, QComboBox, - QHBoxLayout, + QGridLayout, QLabel, QLineEdit, QListView, QListWidget, QListWidgetItem, + QSizePolicy, QVBoxLayout, ) @@ -66,14 +67,29 @@ def setupUi(self, availableDeviceResources): self._add_toolbar() - self.search_layout = QHBoxLayout() - self.verticalLayout.addLayout(self.search_layout) - self.search_layout.addWidget(QLabel("Filter groups: ")) - self.search_box = QLineEdit() - self.search_layout.addWidget(self.search_box) - self.search_layout.addWidget(QLabel("Group by: ")) + # Main area with search and filter using a grid layout + self.search_layout = QVBoxLayout() + self.grid_layout = QGridLayout() + self.grouping_selector = QComboBox() - self.search_layout.addWidget(self.grouping_selector) + self.grouping_selector.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + lbl_group = QLabel("Group by:") + lbl_group.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.grid_layout.addWidget(lbl_group, 0, 0) + self.grid_layout.addWidget(self.grouping_selector, 0, 1) + + self.search_box = QLineEdit() + self.search_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + lbl_filter = QLabel("Filter:") + lbl_filter.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.grid_layout.addWidget(lbl_filter, 1, 0) + self.grid_layout.addWidget(self.search_box, 1, 1) + + self.grid_layout.setColumnStretch(0, 0) + self.grid_layout.setColumnStretch(1, 1) + + self.search_layout.addLayout(self.grid_layout) + self.verticalLayout.addLayout(self.search_layout) self.device_groups_list = _ListOfDeviceGroups( availableDeviceResources, AvailableDeviceGroup From 91e8a72e0721f20090861a81fd9a31259fdb910f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 2 Oct 2025 12:40:59 +0200 Subject: [PATCH 082/161] refactor(device_manager_view): added labels to main toolbar --- .../device_manager_view.py | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 210c71738..c1bed1eab 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -218,7 +218,10 @@ def _add_io_actions(self): io_bundle = ToolbarBundle("IO", self.toolbar.components) load = MaterialIconAction( - icon_name="file_open", parent=self, tooltip="Load configuration file from disk" + icon_name="file_open", + parent=self, + tooltip="Load configuration file from disk", + label_text="Load Config", ) self.toolbar.components.add_safe("load", load) load.action.triggered.connect(self._load_file_action) @@ -226,7 +229,10 @@ def _add_io_actions(self): # Add safe to disk safe_to_disk = MaterialIconAction( - icon_name="file_save", parent=self, tooltip="Save config to disk" + icon_name="file_save", + parent=self, + tooltip="Save config to disk", + label_text="Save Config", ) self.toolbar.components.add_safe("safe_to_disk", safe_to_disk) safe_to_disk.action.triggered.connect(self._save_to_disk_action) @@ -234,7 +240,10 @@ def _add_io_actions(self): # Add load config from redis load_redis = MaterialIconAction( - icon_name="cached", parent=self, tooltip="Load current config from Redis" + icon_name="cached", + parent=self, + tooltip="Load current config from Redis", + label_text="Reload Config", ) load_redis.action.triggered.connect(self._load_redis_action) self.toolbar.components.add_safe("load_redis", load_redis) @@ -242,7 +251,10 @@ def _add_io_actions(self): # Update config action update_config_redis = MaterialIconAction( - icon_name="cloud_upload", parent=self, tooltip="Update current config in Redis" + icon_name="cloud_upload", + parent=self, + tooltip="Update current config in Redis", + label_text="Update Config", ) update_config_redis.action.triggered.connect(self._update_redis_action) self.toolbar.components.add_safe("update_config_redis", update_config_redis) @@ -258,20 +270,27 @@ def _add_table_actions(self) -> None: # Reset composed view reset_composed = MaterialIconAction( - icon_name="delete_sweep", parent=self, tooltip="Reset current composed config view" + icon_name="delete_sweep", + parent=self, + tooltip="Reset current composed config view", + label_text="Reset Config", ) reset_composed.action.triggered.connect(self._reset_composed_view) self.toolbar.components.add_safe("reset_composed", reset_composed) table_bundle.add_action("reset_composed") # Add device - add_device = MaterialIconAction(icon_name="add", parent=self, tooltip="Add new device") + add_device = MaterialIconAction( + icon_name="add", parent=self, tooltip="Add new device", label_text="Add Device" + ) add_device.action.triggered.connect(self._add_device_action) self.toolbar.components.add_safe("add_device", add_device) table_bundle.add_action("add_device") # Remove device - remove_device = MaterialIconAction(icon_name="remove", parent=self, tooltip="Remove device") + remove_device = MaterialIconAction( + icon_name="remove", parent=self, tooltip="Remove device", label_text="Remove Device" + ) remove_device.action.triggered.connect(self._remove_device_action) self.toolbar.components.add_safe("remove_device", remove_device) table_bundle.add_action("remove_device") @@ -281,6 +300,7 @@ def _add_table_actions(self) -> None: icon_name="checklist", parent=self, tooltip="Run device validation with 'connect' on selected devices", + label_text="Rerun Validation", ) rerun_validation.action.triggered.connect(self._rerun_validation_action) self.toolbar.components.add_safe("rerun_validation", rerun_validation) From e52afd569f91f6e2c52eae8594bebc826777cb33 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 24 Sep 2025 12:31:07 -0500 Subject: [PATCH 083/161] feat(busy_loader): busy loader added to bec widget base class --- bec_widgets/utils/bec_widget.py | 109 +++++++++++++ bec_widgets/utils/busy_loader.py | 253 +++++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 bec_widgets/utils/busy_loader.py diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 34a80ee2a..e4e72e525 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -36,6 +36,8 @@ def __init__( config: ConnectionConfig = None, gui_id: str | None = None, theme_update: bool = False, + start_busy: bool = False, + busy_text: str = "Loading…", parent_dock: BECDock | None = None, # TODO should go away -> issue created #473 **kwargs, ): @@ -65,6 +67,20 @@ def __init__( logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") self._connect_to_theme_change() + # Initialize optional busy loader overlay utility (lazy by default) + self._busy_overlay = None + self._loading = False + if start_busy and isinstance(self, QWidget): + try: + overlay = self._ensure_busy_overlay(busy_text=busy_text) + if overlay is not None: + overlay.setGeometry(self.rect()) + overlay.raise_() + overlay.show() + self._loading = True + except Exception as exc: + logger.debug(f"Busy loader init skipped: {exc}") + def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() @@ -81,8 +97,77 @@ def _update_theme(self, theme: str | None = None): theme = qapp.theme.theme else: theme = "dark" + self._update_overlay_theme(theme) self.apply_theme(theme) + def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"): + """Create the busy overlay on demand and cache it in _busy_overlay. + Returns the overlay instance or None if not a QWidget. + """ + if not isinstance(self, QWidget): + return None + overlay = getattr(self, "_busy_overlay", None) + if overlay is None: + from bec_widgets.utils.busy_loader import install_busy_loader + + overlay = install_busy_loader(self, text=busy_text, start_loading=False) + self._busy_overlay = overlay + return overlay + + def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None: + """Create and attach the loading overlay to this widget if QWidget is present.""" + if not isinstance(self, QWidget): + return + self._ensure_busy_overlay(busy_text=busy_text) + if start_busy and self._busy_overlay is not None: + self._busy_overlay.setGeometry(self.rect()) + self._busy_overlay.raise_() + self._busy_overlay.show() + + def set_busy(self, enabled: bool, text: str | None = None) -> None: + """ + Enable/disable the loading overlay. Optionally update the text. + + Args: + enabled(bool): Whether to enable the loading overlay. + text(str, optional): The text to display on the overlay. If None, the text is not changed. + """ + if not isinstance(self, QWidget): + return + if getattr(self, "_busy_overlay", None) is None: + self._ensure_busy_overlay(busy_text=text or "Loading…") + if text is not None: + self.set_busy_text(text) + if enabled: + self._busy_overlay.setGeometry(self.rect()) + self._busy_overlay.raise_() + self._busy_overlay.show() + else: + self._busy_overlay.hide() + self._loading = bool(enabled) + + def is_busy(self) -> bool: + """ + Check if the loading overlay is enabled. + + Returns: + bool: True if the loading overlay is enabled, False otherwise. + """ + return bool(getattr(self, "_loading", False)) + + def set_busy_text(self, text: str) -> None: + """ + Update the text on the loading overlay. + + Args: + text(str): The text to display on the overlay. + """ + overlay = getattr(self, "_busy_overlay", None) + if overlay is None: + overlay = self._ensure_busy_overlay(busy_text=text) + if overlay is not None: + overlay.set_text(text) + @SafeSlot(str) def apply_theme(self, theme: str): """ @@ -92,6 +177,14 @@ def apply_theme(self, theme: str): theme(str, optional): The theme to be applied. """ + def _update_overlay_theme(self, theme: str): + try: + overlay = getattr(self, "_busy_overlay", None) + if overlay is not None and hasattr(overlay, "update_palette"): + overlay.update_palette() + except Exception: + logger.warning(f"Failed to apply theme {theme} to {self}") + @SafeSlot() @SafeSlot(str) @rpc_timeout(None) @@ -150,6 +243,22 @@ def cleanup(self): child.close() child.deleteLater() + # Tear down busy overlay explicitly to stop spinner and remove filters + overlay = getattr(self, "_busy_overlay", None) + if overlay is not None and shiboken6.isValid(overlay): + try: + overlay.hide() + filt = getattr(overlay, "_filter", None) + if filt is not None and shiboken6.isValid(filt): + try: + self.removeEventFilter(filt) + except Exception as exc: + logger.warning(f"Failed to remove event filter from busy overlay: {exc}") + overlay.deleteLater() + except Exception as exc: + logger.warning(f"Failed to delete busy overlay: {exc}") + self._busy_overlay = None + def closeEvent(self, event): """Wrap the close even to ensure the rpc_register is cleaned up.""" try: diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py new file mode 100644 index 000000000..2305170e4 --- /dev/null +++ b/bec_widgets/utils/busy_loader.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from qtpy.QtCore import QEvent, QObject, Qt, QTimer +from qtpy.QtGui import QColor, QFont +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget +from bec_widgets.utils.colors import apply_theme +from bec_widgets.widgets.plots.waveform.waveform import Waveform +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + + +class _OverlayEventFilter(QObject): + """Keeps the overlay sized and stacked over its target widget.""" + + def __init__(self, target: QWidget, overlay: QWidget): + super().__init__(target) + self._target = target + self._overlay = overlay + + def eventFilter(self, obj, event): + if obj is self._target and event.type() in ( + QEvent.Resize, + QEvent.Show, + QEvent.LayoutRequest, + QEvent.Move, + ): + self._overlay.setGeometry(self._target.rect()) + self._overlay.raise_() + return False + + +class BusyLoaderOverlay(QWidget): + """ + A semi-transparent scrim with centered text and an animated spinner. + Call show()/hide() directly, or use via `install_busy_loader(...)`. + + Args: + parent(QWidget): The parent widget to overlay. + text(str): Initial text to display. + opacity(float): Overlay opacity (0..1). + + Returns: + BusyLoaderOverlay: The overlay instance. + """ + + def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs): + super().__init__(parent=parent, **kwargs) + self.setAttribute(Qt.WA_StyledBackground, True) + self.setAutoFillBackground(False) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self._opacity = opacity + + self._label = QLabel(text, self) + self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + f = QFont(self._label.font()) + f.setBold(True) + f.setPointSize(f.pointSize() + 1) + self._label.setFont(f) + + self._spinner = SpinnerWidget(self) + self._spinner.setFixedSize(42, 42) + + lay = QVBoxLayout(self) + lay.setContentsMargins(24, 24, 24, 24) + lay.setSpacing(10) + lay.addStretch(1) + lay.addWidget(self._spinner, 0, Qt.AlignHCenter) + lay.addWidget(self._label, 0, Qt.AlignHCenter) + lay.addStretch(1) + + self._frame = QFrame(self) + self._frame.setObjectName("busyFrame") + self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True) + self._frame.lower() + + # Defaults + self._scrim_color = QColor(0, 0, 0, 110) + self._label_color = QColor(240, 240, 240) + self.update_palette() + + # Start hidden; interactions beneath are blocked while visible + self.hide() + + # --- API --- + def set_text(self, text: str): + """ + Update the overlay text. + + Args: + text(str): The text to display on the overlay. + """ + self._label.setText(text) + + def set_opacity(self, opacity: float): + """ + Set overlay opacity (0..1). + + Args: + opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque). + """ + self._opacity = max(0.0, min(1.0, float(opacity))) + # Re-apply alpha using the current theme color + if isinstance(self._scrim_color, QColor): + base = QColor(self._scrim_color) + base.setAlpha(int(255 * self._opacity)) + self._scrim_color = base + self.update() + + def update_palette(self): + """ + Update colors from the current application theme. + """ + app = QApplication.instance() + if hasattr(app, "theme"): + theme = app.theme # type: ignore[attr-defined] + self._bg = theme.color("BORDER") + self._fg = theme.color("FG") + self._primary = theme.color("PRIMARY") + else: + # Fallback neutrals + self._bg = QColor(30, 30, 30) + self._fg = QColor(230, 230, 230) + # Semi-transparent scrim derived from bg + self._scrim_color = QColor(self._bg) + self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35))))) + self._spinner.update() + fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg) + self._label.setStyleSheet(f"color: {fg_hex};") + self._frame.setStyleSheet( + f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}" + ) + self.update() + + # --- QWidget overrides --- + def showEvent(self, e): + self._spinner.start() + super().showEvent(e) + + def hideEvent(self, e): + self._spinner.stop() + super().hideEvent(e) + + def resizeEvent(self, e): + super().resizeEvent(e) + r = self.rect().adjusted(10, 10, -10, -10) + self._frame.setGeometry(r) + + def paintEvent(self, e): + super().paintEvent(e) + + +def install_busy_loader( + target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35 +) -> BusyLoaderOverlay: + """ + Attach a BusyLoaderOverlay to `target` and keep it sized and stacked. + + Args: + target(QWidget): The widget to overlay. + text(str): Initial text to display. + start_loading(bool): If True, show the overlay immediately. + opacity(float): Overlay opacity (0..1). + + Returns: + BusyLoaderOverlay: The overlay instance. + """ + overlay = BusyLoaderOverlay(target, text=text, opacity=opacity) + overlay.setGeometry(target.rect()) + filt = _OverlayEventFilter(target, overlay) + overlay._filter = filt # type: ignore[attr-defined] + target.installEventFilter(filt) + if start_loading: + overlay.show() + return overlay + + +# -------------------------- +# Launchable demo +# -------------------------- +class DemoWidget(BECWidget, QWidget): # pragma: no cover + def __init__(self, parent=None): + super().__init__( + parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…" + ) + + self._title = QLabel("Demo Content", self) + self._title.setAlignment(Qt.AlignCenter) + self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken) + lay = QVBoxLayout(self) + lay.addWidget(self._title) + waveform = Waveform(self) + waveform.plot([1, 2, 3, 4, 5]) + lay.addWidget(waveform, 1) + + QTimer.singleShot(5000, self._ready) + + def _ready(self): + self._title.setText("Ready ✓") + self.set_busy(False) + + +class DemoWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Busy Loader — BECWidget demo") + + left = DemoWidget() + right = DemoWidget() + + btn_on = QPushButton("Right → Loading") + btn_off = QPushButton("Right → Ready") + btn_text = QPushButton("Set custom text") + btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…")) + btn_off.clicked.connect(lambda: right.set_busy(False)) + btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…")) + + panel = QWidget() + prow = QVBoxLayout(panel) + prow.addWidget(btn_on) + prow.addWidget(btn_off) + prow.addWidget(btn_text) + prow.addStretch(1) + + central = QWidget() + row = QHBoxLayout(central) + row.setContentsMargins(12, 12, 12, 12) + row.setSpacing(12) + row.addWidget(left, 1) + row.addWidget(right, 1) + row.addWidget(panel, 0) + + self.setCentralWidget(central) + self.resize(900, 420) + + +if __name__ == "__main__": # pragma: no cover + import sys + + app = QApplication(sys.argv) + apply_theme("light") + w = DemoWindow() + w.show() + sys.exit(app.exec()) From 8b9295356d02d6768b56aaa436a48111529d1641 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 29 Sep 2025 13:30:44 +0200 Subject: [PATCH 084/161] test(busy_loader): tests added --- tests/unit_tests/test_busy_loader.py | 145 +++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/unit_tests/test_busy_loader.py diff --git a/tests/unit_tests/test_busy_loader.py b/tests/unit_tests/test_busy_loader.py new file mode 100644 index 000000000..2f9e859c2 --- /dev/null +++ b/tests/unit_tests/test_busy_loader.py @@ -0,0 +1,145 @@ +import pytest +from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + +from bec_widgets import BECWidget + +from .client_mocks import mocked_client + + +class _TestBusyWidget(BECWidget, QWidget): + def __init__( + self, + parent=None, + *, + start_busy: bool = False, + busy_text: str = "Loading…", + theme_update: bool = False, + **kwargs, + ): + super().__init__( + parent=parent, + theme_update=theme_update, + start_busy=start_busy, + busy_text=busy_text, + **kwargs, + ) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(QLabel("content", self)) + + +@pytest.fixture +def widget_busy(qtbot, mocked_client): + w = _TestBusyWidget(client=mocked_client, start_busy=True, busy_text="Initializing…") + qtbot.addWidget(w) + w.resize(320, 200) + w.show() + qtbot.waitExposed(w) + return w + + +@pytest.fixture +def widget_idle(qtbot): + w = _TestBusyWidget(client=mocked_client, start_busy=False) + qtbot.addWidget(w) + w.resize(320, 200) + w.show() + qtbot.waitExposed(w) + return w + + +def test_becwidget_start_busy_shows_overlay(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay", None) + assert overlay is not None, "BECWidget should create a busy overlay in __init__" + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + qtbot.waitUntil(lambda: overlay.isVisible()) + + +def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle): + overlay = getattr(widget_idle, "_busy_overlay", None) + assert overlay is None, "Overlay should be lazily created when idle" + + widget_idle.set_busy(True, "Fetching data…") + overlay = getattr(widget_idle, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + lbl = getattr(overlay, "_label") + assert lbl.text() == "Fetching data…" + + widget_idle.set_busy(False) + qtbot.waitUntil(lambda: overlay.isHidden()) + + +def test_becwidget_overlay_tracks_resize(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + + widget_busy.resize(480, 260) + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + + +def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + frame = getattr(overlay, "_frame", None) + assert frame is not None, "Busy overlay must use an internal QFrame for visuals" + + # Insets are 10 px in the implementation + outer = overlay.rect() + # Ensure resizeEvent has run and frame geometry is updated + qtbot.waitUntil( + lambda: frame.geometry().width() == outer.width() - 20 + and frame.geometry().height() == outer.height() - 20 + ) + + inner = frame.geometry() + assert inner.left() == outer.left() + 10 + assert inner.top() == outer.top() + 10 + assert inner.right() == outer.right() - 10 + assert inner.bottom() == outer.bottom() - 10 + + # Style: dashed border + semi-transparent grey background + ss = frame.styleSheet() + assert "dashed" in ss + assert "border" in ss + assert "rgba(128, 128, 128, 110)" in ss + + +def test_becwidget_apply_busy_text_without_toggle(qtbot, widget_idle): + overlay = getattr(widget_idle, "_busy_overlay", None) + assert overlay is None, "Overlay should be created on first text update" + + widget_idle.set_busy_text("Preparing…") + overlay = getattr(widget_idle, "_busy_overlay") + assert overlay is not None + assert overlay.isHidden() + + lbl = getattr(overlay, "_label") + assert lbl.text() == "Preparing…" + + +def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay", None) + assert overlay is not None, "Busy overlay should exist on a start_busy widget" + + # Initially visible because start_busy=True + qtbot.waitUntil(lambda: overlay.isVisible()) + + # Switch OFF + widget_busy.set_busy(False) + qtbot.waitUntil(lambda: overlay.isHidden()) + + # Switch ON again (with new text) + widget_busy.set_busy(True, "Back to work…") + qtbot.waitUntil(lambda: overlay.isVisible()) + + # Same overlay instance reused (no duplication) + assert getattr(widget_busy, "_busy_overlay") is overlay + + # Label updated + lbl = getattr(overlay, "_label") + assert lbl.text() == "Back to work…" + + # Geometry follows parent after re-show + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) From c609a2c9cbfa34be13b2876c2852319cb25096b9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 29 Sep 2025 14:45:12 +0200 Subject: [PATCH 085/161] test(device_input_base): added qtbot --- tests/unit_tests/test_device_input_base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/test_device_input_base.py b/tests/unit_tests/test_device_input_base.py index 02ae550d8..7ab73e946 100644 --- a/tests/unit_tests/test_device_input_base.py +++ b/tests/unit_tests/test_device_input_base.py @@ -43,7 +43,7 @@ def test_device_input_base_init(device_input_base): assert device_input_base.devices == [] -def test_device_input_base_init_with_config(mocked_client): +def test_device_input_base_init_with_config(qtbot, mocked_client): """Test init with Config""" config = { "widget_class": "DeviceInputWidget", @@ -55,6 +55,10 @@ def test_device_input_base_init_with_config(mocked_client): widget2 = DeviceInputWidget( client=mocked_client, config=DeviceInputConfig.model_validate(config) ) + qtbot.addWidget(widget) + qtbot.addWidget(widget2) + qtbot.waitExposed(widget) + qtbot.waitExposed(widget2) for w in [widget, widget2]: assert w.config.gui_id == "test_gui_id" assert w.config.device_filter == ["Positioner"] From 60c207f1fe29970d6db58a2db040835d86e5c1ff Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 3 Oct 2025 12:19:15 +0200 Subject: [PATCH 086/161] test(color_utils): cleanup for pyqtgraph --- tests/unit_tests/test_color_utils.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit_tests/test_color_utils.py b/tests/unit_tests/test_color_utils.py index 7628e8ae4..ac2ef246e 100644 --- a/tests/unit_tests/test_color_utils.py +++ b/tests/unit_tests/test_color_utils.py @@ -144,6 +144,19 @@ def __init__( self.glw.addItem(self.pi) self.pi.plot([1, 2, 3, 4, 5], pen="r") + def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None): + """Cleanup pyqtgraph items.""" + if item is None: + item = self.pi + item.vb.menu.close() + item.vb.menu.deleteLater() + item.ctrlMenu.close() + item.ctrlMenu.deleteLater() + + def cleanup(self): + self.cleanup_pyqtgraph() + super().cleanup() + def test_apply_theme(qtbot, mocked_client): widget = create_widget(qtbot, ExamplePlotWidget, client=mocked_client) From de5c2e37edc87c1ef48bf8f3afae5f33eff51bcc Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 3 Oct 2025 13:19:07 +0200 Subject: [PATCH 087/161] fix(client): abort, reset, stop button removed from RPC access --- bec_widgets/cli/client.py | 69 ------------------- .../buttons/button_abort/button_abort.py | 2 +- .../buttons/button_reset/button_reset.py | 2 +- .../buttons/stop_button/stop_button.py | 2 +- .../test_user_interaction_e2e.py | 66 ------------------ 5 files changed, 3 insertions(+), 138 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 1d3a8a334..fe18dad65 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -27,7 +27,6 @@ class _WidgetsEnumType(str, enum.Enum): _Widgets = { - "AbortButton": "AbortButton", "BECDockArea": "BECDockArea", "BECMainWindow": "BECMainWindow", "BECProgressBar": "BECProgressBar", @@ -50,7 +49,6 @@ class _WidgetsEnumType(str, enum.Enum): "PositionerBox2D": "PositionerBox2D", "PositionerControlLine": "PositionerControlLine", "PositionerGroup": "PositionerGroup", - "ResetButton": "ResetButton", "ResumeButton": "ResumeButton", "RingProgressBar": "RingProgressBar", "SBBMonitor": "SBBMonitor", @@ -60,7 +58,6 @@ class _WidgetsEnumType(str, enum.Enum): "SignalComboBox": "SignalComboBox", "SignalLabel": "SignalLabel", "SignalLineEdit": "SignalLineEdit", - "StopButton": "StopButton", "TextBox": "TextBox", "VSCodeEditor": "VSCodeEditor", "Waveform": "Waveform", @@ -97,28 +94,6 @@ class _WidgetsEnumType(str, enum.Enum): logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}") -class AbortButton(RPCBase): - """A button that abort the scan.""" - - @rpc_call - 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 AdvancedDockArea(RPCBase): @rpc_call def new( @@ -4052,28 +4027,6 @@ def set_position(self, x: "float", y: "float"): """ -class ResetButton(RPCBase): - """A button that resets the scan queue.""" - - @rpc_call - 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.""" @@ -5049,28 +5002,6 @@ def signals(self) -> list[str]: """ -class StopButton(RPCBase): - """A button that stops the current scan.""" - - @rpc_call - 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""" 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 7adc8d4ca..4a9585dc2 100644 --- a/bec_widgets/widgets/control/buttons/button_abort/button_abort.py +++ b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py @@ -11,7 +11,7 @@ class AbortButton(BECWidget, QWidget): PLUGIN = True ICON_NAME = "cancel" - RPC = True + RPC = False def __init__( self, diff --git a/bec_widgets/widgets/control/buttons/button_reset/button_reset.py b/bec_widgets/widgets/control/buttons/button_reset/button_reset.py index caea1cc71..dc468a318 100644 --- a/bec_widgets/widgets/control/buttons/button_reset/button_reset.py +++ b/bec_widgets/widgets/control/buttons/button_reset/button_reset.py @@ -11,7 +11,7 @@ class ResetButton(BECWidget, QWidget): PLUGIN = True ICON_NAME = "restart_alt" - RPC = True + RPC = False def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) 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 fdedf4f1f..218fa2e98 100644 --- a/bec_widgets/widgets/control/buttons/stop_button/stop_button.py +++ b/bec_widgets/widgets/control/buttons/stop_button/stop_button.py @@ -11,7 +11,7 @@ class StopButton(BECWidget, QWidget): PLUGIN = True ICON_NAME = "dangerous" - RPC = True + RPC = False def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) diff --git a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py index 98fb26c8b..2ae82b988 100644 --- a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py +++ b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py @@ -139,25 +139,6 @@ def maybe_remove_dock_area(qtbot, gui: BECGuiClient, random_int_gen: random.Rand ) -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_abort_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the AbortButton widget.""" - gui: BECGuiClient = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.AbortButton) - dock: client.BECDock - widget: client.AbortButton - - # No rpc calls to check so far - - # Try detaching the dock - dock.detach() - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - @pytest.mark.timeout(PYTEST_TIMEOUT) def test_widgets_e2e_bec_progress_bar(qtbot, connected_client_gui_obj, random_generator_from_seed): """Test the BECProgressBar widget.""" @@ -637,53 +618,6 @@ def test_widgets_e2e_scatter_waveform(qtbot, connected_client_gui_obj, random_ge maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_stop_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the StopButton widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.StopButton) - dock: client.BECDock - widget: client.StopButton - - # No rpc calls to check so far - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_resume_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the StopButton widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResumeButton) - dock: client.BECDock - widget: client.ResumeButton - - # No rpc calls to check so far - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - -@pytest.mark.timeout(PYTEST_TIMEOUT) -def test_widgets_e2e_reset_button(qtbot, connected_client_gui_obj, random_generator_from_seed): - """Test the StopButton widget""" - gui = connected_client_gui_obj - bec = gui._client - # Create dock_area, dock, widget - dock, widget = create_widget(qtbot, gui, gui.available_widgets.ResetButton) - dock: client.BECDock - widget: client.ResetButton - # No rpc calls to check so far - - # Test removing the widget, or leaving it open for the next test - maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) - - @pytest.mark.timeout(PYTEST_TIMEOUT) def test_widgets_e2e_text_box(qtbot, connected_client_gui_obj, random_generator_from_seed): """Test the TextBox widget""" From b2af9c421c315123091158a7d6310ddf40518a92 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 3 Oct 2025 17:57:03 +0200 Subject: [PATCH 088/161] fix(signal_label): dispatcher unsubscribed in the cleanup --- bec_widgets/widgets/utility/signal_label/signal_label.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bec_widgets/widgets/utility/signal_label/signal_label.py b/bec_widgets/widgets/utility/signal_label/signal_label.py index ebf80c9a2..f394db179 100644 --- a/bec_widgets/widgets/utility/signal_label/signal_label.py +++ b/bec_widgets/widgets/utility/signal_label/signal_label.py @@ -8,7 +8,6 @@ from bec_lib.device import Device, Signal from bec_lib.endpoints import MessageEndpoints from bec_qthemes import material_icon -from qtpy.QtCore import Qt from qtpy.QtCore import Signal as QSignal from qtpy.QtWidgets import ( QApplication, @@ -483,6 +482,11 @@ def _update_label(self): self._custom_label if self._custom_label else f"{self._default_label}:" ) + def cleanup(self): + self.disconnect_device() + self._device_obj = None + super().cleanup() + if __name__ == "__main__": app = QApplication(sys.argv) From 8e66bae6ec694147531b84544f380949425deb0e Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 7 Oct 2025 13:44:59 +0200 Subject: [PATCH 089/161] feat(help-inspector): add help inspector widget --- bec_widgets/utils/bec_widget.py | 8 + bec_widgets/utils/help_inspector/__init__.py | 0 .../utils/help_inspector/help_inspector.py | 238 ++++++++++++++++++ tests/unit_tests/test_help_inspector.py | 81 ++++++ 4 files changed, 327 insertions(+) create mode 100644 bec_widgets/utils/help_inspector/__init__.py create mode 100644 bec_widgets/utils/help_inspector/help_inspector.py create mode 100644 tests/unit_tests/test_help_inspector.py diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index e4e72e525..02c4d6076 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -185,6 +185,14 @@ def _update_overlay_theme(self, theme: str): except Exception: logger.warning(f"Failed to apply theme {theme} to {self}") + def get_help_md(self) -> str: + """ + Method to override in subclasses to provide help text in markdown format. + + Returns: + str: The help text in markdown format. + """ + @SafeSlot() @SafeSlot(str) @rpc_timeout(None) diff --git a/bec_widgets/utils/help_inspector/__init__.py b/bec_widgets/utils/help_inspector/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/utils/help_inspector/help_inspector.py b/bec_widgets/utils/help_inspector/help_inspector.py new file mode 100644 index 000000000..e9976945e --- /dev/null +++ b/bec_widgets/utils/help_inspector/help_inspector.py @@ -0,0 +1,238 @@ +"""Module providing a simple help inspector tool for QtWidgets.""" + +from functools import partial +from typing import Callable +from uuid import uuid4 + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import AccentColors, get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + + +class HelpInspector(BECWidget, QtWidgets.QWidget): + """ + A help inspector widget that allows to inspect other widgets in the application. + Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget. + The method "get_help_md" is called on the widget which is added to the BECWidget base class. + It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown). + The inspector also allows to register custom callback that are called with the inspected widget + as argument. This may be useful in the future to hook up more callbacks with custom signals. + + Args: + parent (QWidget | None): The parent widget of the help inspector. + client: Optional client for BECWidget functionality. + size (tuple[int, int]): Optional size of the icon for the help inspector. + """ + + widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget + widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget + bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class + + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent, theme_update=True) + self._app = QtWidgets.QApplication.instance() + layout = QtWidgets.QHBoxLayout(self) # type: ignore + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._active = False + self._init_ui() + self._callbacks = {} + # Register the default callbacks + self._register_default_callbacks() + # Connect the button toggle signal + self._button.toggled.connect(self._toggle_mode) + + def _init_ui(self): + """Init the UI components.""" + colors: AccentColors = get_accent_colors() + self._button = QtWidgets.QToolButton(self.parent()) + self._button.setCheckable(True) + + self._icon_checked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=True + ) + self._icon_unchecked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=False + ) + self._button.setText("Help Inspect Tool") + self._button.setIcon(self._icon_unchecked()) + self._button.setToolTip("Click to enter Help Mode") + self.layout().addWidget(self._button) + + def apply_theme(self, theme: str) -> None: + colors = get_accent_colors() + self._icon_checked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=True + ) + self._icon_unchecked = partial( + material_icon, "help", size=(32, 32), color=colors.highlight, filled=False + ) + if self._active: + self._button.setIcon(self._icon_checked()) + else: + self._button.setIcon(self._icon_unchecked()) + + @SafeSlot(bool) + def _toggle_mode(self, enabled: bool): + """ + Toggle the help inspection mode. + + Args: + enabled (bool): Whether to enable or disable the help inspection mode. + """ + if self._app is None: + self._app = QtWidgets.QApplication.instance() + self._active = enabled + if enabled: + self._app.installEventFilter(self) + self._button.setIcon(self._icon_checked()) + QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor) + else: + self._app.removeEventFilter(self) + self._button.setIcon(self._icon_unchecked()) + self._button.setChecked(False) + QtWidgets.QApplication.restoreOverrideCursor() + + def eventFilter(self, obj, event): + """ + Filter events to capture Key_Escape event, and mouse clicks + if event filter is active. Any click event on a widget is suppressed, if + the Inspector is active, and the registered callbacks are called with + the clicked widget as argument. + + Args: + obj (QObject): The object that received the event. + event (QEvent): The event to filter. + """ + if ( + event.type() == QtCore.QEvent.KeyPress + and event.key() == QtCore.Qt.Key_Escape + and self._active + ): + self._toggle_mode(False) + return super().eventFilter(obj, event) + if self._active and event.type() == QtCore.QEvent.MouseButtonPress: + if event.button() == QtCore.Qt.LeftButton: + widget = self._app.widgetAt(event.globalPos()) + if widget: + if widget is self or self.isAncestorOf(widget): + self._toggle_mode(False) + return True + for cb in self._callbacks.values(): + try: + cb(widget) + except Exception as e: + print(f"Error occurred in callback {cb}: {e}") + return True + return super().eventFilter(obj, event) + + def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str: + """ + Register a callback to be called when a widget is inspected. + The callback should be callable with the following signature: + callback(widget: QWidget) -> None + + Args: + callback (Callable[[QWidget], None]): The callback function to register. + Returns: + str: A unique ID for the registered callback. + """ + cb_id = str(uuid4()) + self._callbacks[cb_id] = callback + return cb_id + + def unregister_callback(self, cb_id: str): + """Unregister a previously registered callback.""" + self._callbacks.pop(cb_id, None) + + def _register_default_callbacks(self): + """Default behavior: publish tooltip, docstring, bec_help""" + + def cb_doc(widget: QtWidgets.QWidget): + docstring = widget.__doc__ or "No documentation available." + self.widget_docstring.emit(docstring) + + def cb_help(widget: QtWidgets.QWidget): + tooltip = widget.toolTip() or "No tooltip available." + self.widget_tooltip.emit(tooltip) + + def cb_bec_help(widget: QtWidgets.QWidget): + help_text = None + if hasattr(widget, "get_help_md") and callable(widget.get_help_md): + try: + help_text = widget.get_help_md() + except Exception as e: + logger.debug(f"Error retrieving help text from {widget}: {e}") + if help_text is None: + help_text = widget.toolTip() or "No help available." + if not isinstance(help_text, str): + logger.error( + f"Help text from {widget.__class__} is not a string: {type(help_text)}" + ) + help_text = str(help_text) + self.bec_widget_help.emit(help_text) + + self.register_callback(cb_doc) + self.register_callback(cb_help) + self.register_callback(cb_bec_help) + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + + from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + app = QtWidgets.QApplication(sys.argv) + + main_window = QtWidgets.QMainWindow() + apply_theme("dark") + main_window.setWindowTitle("Help Inspector Test") + + central_widget = QtWidgets.QWidget() + main_layout = QtWidgets.QVBoxLayout(central_widget) + dark_mode_button = DarkModeButton(parent=main_window) + main_layout.addWidget(dark_mode_button) + + help_inspector = HelpInspector() + main_layout.addWidget(help_inspector) + + test_button = QtWidgets.QPushButton("Test Button") + test_button.setToolTip("This is a test button.") + test_line_edit = QtWidgets.QLineEdit() + test_line_edit.setToolTip("This is a test line edit.") + test_label = QtWidgets.QLabel("Test Label") + test_label.setToolTip("") + box = PositionerBox() + + layout_1 = QtWidgets.QHBoxLayout() + layout_1.addWidget(test_button) + layout_1.addWidget(test_line_edit) + layout_1.addWidget(test_label) + layout_1.addWidget(box) + main_layout.addLayout(layout_1) + + doc_label = QtWidgets.QLabel("Docstring will appear here.") + tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.") + bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.") + main_layout.addWidget(doc_label) + main_layout.addWidget(tool_tip_label) + main_layout.addWidget(bec_help_label) + + help_inspector.widget_tooltip.connect(tool_tip_label.setText) + help_inspector.widget_docstring.connect(doc_label.setText) + help_inspector.bec_widget_help.connect(bec_help_label.setText) + + main_window.setCentralWidget(central_widget) + main_window.resize(400, 200) + main_window.show() + sys.exit(app.exec()) diff --git a/tests/unit_tests/test_help_inspector.py b/tests/unit_tests/test_help_inspector.py new file mode 100644 index 000000000..75cd738b6 --- /dev/null +++ b/tests/unit_tests/test_help_inspector.py @@ -0,0 +1,81 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +import pytest +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton + +from .client_mocks import mocked_client + + +@pytest.fixture +def help_inspector(qtbot, mocked_client): + widget = HelpInspector(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def abort_button(qtbot): + widget = AbortButton() + widget.setToolTip("This is an abort button.") + + def get_help_md(): + return "This is **markdown** help text for the abort button." + + widget.get_help_md = get_help_md # type: ignore + + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + yield widget + + +def test_help_inspector_button(help_inspector): + """Test the HelpInspector widget.""" + assert not help_inspector._active + help_inspector._button.click() + assert help_inspector._active + assert help_inspector._button.isChecked() + cursor = QtWidgets.QApplication.overrideCursor() + assert cursor is not None + assert cursor.shape() == QtCore.Qt.CursorShape.WhatsThisCursor + help_inspector._button.click() + assert not help_inspector._active + assert not help_inspector._button.isChecked() + assert QtWidgets.QApplication.overrideCursor() is None + + +def test_help_inspector_register_callback(help_inspector): + """Test registering a callback in the HelpInspector widget.""" + + assert len(help_inspector._callbacks) == 3 # default callbacks + + def my_callback(widget): + pass + + cb_id = help_inspector.register_callback(my_callback) + assert len(help_inspector._callbacks) == 4 + assert help_inspector._callbacks[cb_id] == my_callback + + cb_id2 = help_inspector.register_callback(my_callback) + assert len(help_inspector._callbacks) == 5 + assert help_inspector._callbacks[cb_id2] == my_callback + + help_inspector.unregister_callback(cb_id) + assert len(help_inspector._callbacks) == 4 + + help_inspector.unregister_callback(cb_id2) + assert len(help_inspector._callbacks) == 3 + + +def test_help_inspector_escape_key(qtbot, help_inspector): + """Test that pressing the Escape key deactivates the HelpInspector.""" + help_inspector._button.click() + assert help_inspector._active + qtbot.keyClick(help_inspector, QtCore.Qt.Key.Key_Escape) + assert not help_inspector._active + assert not help_inspector._button.isChecked() + assert QtWidgets.QApplication.overrideCursor() is None From cbf9d21b7dd2f1056e666006ba2c2062420b51cf Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 7 Oct 2025 13:24:12 +0200 Subject: [PATCH 090/161] fix(advanced_dock_area): vs code removed from available docks --- .../widgets/containers/advanced_dock_area/advanced_dock_area.py | 2 -- 1 file changed, 2 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 4b0e11f9a..20c2512e3 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 @@ -52,7 +52,6 @@ from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox from bec_widgets.widgets.control.scan_control import ScanControl -from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap from bec_widgets.widgets.plots.image.image import Image from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap @@ -321,7 +320,6 @@ def _setup_toolbar(self): } 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, From c97c50ffdb419547365de4208dfcb679fbe8e612 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 7 Oct 2025 13:35:03 +0200 Subject: [PATCH 091/161] fix(web_console): added startup kwarg --- .../widgets/editors/web_console/web_console.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bec_widgets/widgets/editors/web_console/web_console.py b/bec_widgets/widgets/editors/web_console/web_console.py index e0ad7d4bd..62eede57e 100644 --- a/bec_widgets/widgets/editors/web_console/web_console.py +++ b/bec_widgets/widgets/editors/web_console/web_console.py @@ -172,9 +172,17 @@ class WebConsole(BECWidget, QWidget): PLUGIN = True ICON_NAME = "terminal" - def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + def __init__( + self, + parent=None, + config=None, + client=None, + gui_id=None, + startup_cmd: str | None = "bec --nogui", + **kwargs, + ): super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - self._startup_cmd = "bec --nogui" + self._startup_cmd = startup_cmd self._is_initialized = False _web_console_registry.register(self) self._token = _web_console_registry._token From caaa9e5d7f1fc047ded1036dd866b14674886221 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 7 Oct 2025 14:00:24 +0200 Subject: [PATCH 092/161] fix(advanced_dock_area): added terminal,bec_shell, removed VSCode --- bec_widgets/cli/client.py | 2 ++ .../advanced_dock_area/advanced_dock_area.py | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index fe18dad65..63e835ba9 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -104,6 +104,7 @@ def new( movable: "bool" = True, start_floating: "bool" = False, where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, + **kwargs, ) -> "BECWidget": """ Create a new widget (or reuse an instance) and add it as a dock. @@ -116,6 +117,7 @@ def new( 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. + **kwargs: The keyword arguments for the widget. Returns: The widget instance. """ 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 20c2512e3..5a5ab6038 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 @@ -52,6 +52,7 @@ 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.web_console.web_console import WebConsole 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 @@ -326,6 +327,8 @@ def _setup_toolbar(self): "Add Circular ProgressBar", "RingProgressBar", ), + "terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"), + "bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"), "log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), "sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), } @@ -442,6 +445,18 @@ def _connect_menu(menu_key: str): act = menu.actions[key].action if widget_type == "LogPanel": act.setEnabled(False) # keep disabled per issue #644 + elif key == "terminal": + act.triggered.connect( + lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None) + ) + elif key == "bec_shell": + act.triggered.connect( + lambda _, t=widget_type: self.new( + widget=t, + closable=True, + startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}", + ) + ) else: act.triggered.connect(lambda _, t=widget_type: self.new(widget=t)) @@ -516,6 +531,7 @@ def new( movable: bool = True, start_floating: bool = False, where: Literal["left", "right", "top", "bottom"] | None = None, + **kwargs, ) -> BECWidget: """ Create a new widget (or reuse an instance) and add it as a dock. @@ -528,6 +544,7 @@ def new( 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. + **kwargs: The keyword arguments for the widget. Returns: The widget instance. """ @@ -535,7 +552,9 @@ def new( # 1) Instantiate or look up the widget if isinstance(widget, str): - widget = cast(BECWidget, widget_handler.create_widget(widget_type=widget, parent=self)) + widget = cast( + BECWidget, widget_handler.create_widget(widget_type=widget, parent=self, **kwargs) + ) widget.name_established.connect( lambda: self._create_dock_with_name( widget=widget, From 4e38365d00e69765500b86e17202b9712d07f740 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 10 Oct 2025 11:38:46 +0200 Subject: [PATCH 093/161] feat(advanced_dock_area): add 2D positioner box to device actions --- .../containers/advanced_dock_area/advanced_dock_area.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 5a5ab6038..8bdee4a7e 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 @@ -50,7 +50,7 @@ workspace_bundle, ) from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC -from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox +from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D from bec_widgets.widgets.control.scan_control import ScanControl from bec_widgets.widgets.editors.web_console.web_console import WebConsole from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap @@ -318,6 +318,11 @@ def _setup_toolbar(self): DEVICE_ACTIONS = { "scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"), "positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"), + "positioner_box_2D": ( + PositionerBox2D.ICON_NAME, + "Add Device 2D Box", + "PositionerBox2D", + ), } UTIL_ACTIONS = { "queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"), From 979de3519b576cf796d77cccda3be26072ead2ee Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 10 Oct 2025 13:05:07 +0200 Subject: [PATCH 094/161] test(advanced_dock_area): tests fixed --- tests/unit_tests/test_advanced_dock_area.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 755571234..d6563a33e 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -306,7 +306,7 @@ def test_toolbar_device_actions(self, advanced_dock_area): 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"] + utils_actions = ["queue", "terminal", "status", "progress_bar", "sbb_monitor"] for action_name in utils_actions: with patch.object(advanced_dock_area, "new") as mock_new: @@ -322,7 +322,12 @@ def test_toolbar_utils_actions(self, advanced_dock_area): widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_utils"][action_name][2] action.trigger() - mock_new.assert_called_once_with(widget=widget_type) + if action_name == "terminal": + mock_new.assert_called_once_with( + widget="WebConsole", closable=True, startup_cmd=None + ) + else: + mock_new.assert_called_once_with(widget=widget_type) def test_attach_all_action(self, advanced_dock_area, qtbot): """Test attach_all toolbar action.""" @@ -874,9 +879,10 @@ 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_terminal", + "flat_bec_shell", "flat_log_panel", "flat_sbb_monitor", ] @@ -918,9 +924,10 @@ 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_terminal": "WebConsole", + "flat_bec_shell": "WebConsole", "flat_sbb_monitor": "SBBMonitor", } From 3522caba6722cca36d8543968da13992920b6526 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 3 Oct 2025 16:28:32 +0200 Subject: [PATCH 095/161] refactor: improve device-manager-view --- bec_widgets/applications/main_app.py | 1 + .../device_manager_view.py | 235 +++++++++++++----- bec_widgets/utils/bec_widget.py | 1 + .../utils/help_inspector/help_inspector.py | 26 +- .../device_manager/components/constants.py | 55 ++++ .../components/device_table_view.py | 232 ++++++++++++++--- .../components/dm_ophyd_test.py | 44 ++-- 7 files changed, 482 insertions(+), 112 deletions(-) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 1f90dd0eb..da210c973 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -195,6 +195,7 @@ def _on_view_selected(self, vid: str) -> None: app = QApplication([sys.argv[0], *qt_args]) apply_theme("dark") w = BECMainApp(show_examples=args.examples) + w.resize(1920, 1200) w.show() sys.exit(app.exec()) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index c1bed1eab..240f2a4fe 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -14,10 +14,23 @@ from bec_qthemes import apply_theme from PySide6QtAds import CDockManager, CDockWidget from qtpy.QtCore import Qt, QThreadPool, QTimer -from qtpy.QtWidgets import QFileDialog, QMessageBox, QSplitter, QVBoxLayout, QWidget +from qtpy.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QTextEdit, + QVBoxLayout, + QWidget, +) from bec_widgets import BECWidget from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar @@ -83,6 +96,53 @@ def apply(): QTimer.singleShot(0, apply) +class ConfigChoiceDialog(QDialog): + REPLACE = 1 + ADD = 2 + CANCEL = 0 + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Load Config") + layout = QVBoxLayout(self) + + label = QLabel("Do you want to replace the current config or add to it?") + label.setWordWrap(True) + layout.addWidget(label) + + # Buttons: equal size, stacked vertically + self.replace_btn = QPushButton("Replace") + self.add_btn = QPushButton("Add") + self.cancel_btn = QPushButton("Cancel") + btn_layout = QHBoxLayout() + for btn in (self.replace_btn, self.add_btn, self.cancel_btn): + btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + btn_layout.addWidget(btn) + layout.addLayout(btn_layout) + + # Connect signals to explicit slots + self.replace_btn.clicked.connect(self.accept_replace) + self.add_btn.clicked.connect(self.accept_add) + self.cancel_btn.clicked.connect(self.reject_cancel) + + self._result = self.CANCEL + + def accept_replace(self): + self._result = self.REPLACE + self.accept() + + def accept_add(self): + self._result = self.ADD + self.accept() + + def reject_cancel(self): + self._result = self.CANCEL + self.reject() + + def result(self): + return self._result + + class DeviceManagerView(BECWidget, QWidget): def __init__(self, parent=None, *args, **kwargs): @@ -99,14 +159,14 @@ def __init__(self, parent=None, *args, **kwargs): self.dock_manager.setStyleSheet("") self._root_layout.addWidget(self.dock_manager) - # Available Resources Widget - self.available_devices = AvailableDeviceResources( - self, shared_selection_signal=self._shared_selection - ) - self.available_devices_dock = QtAds.CDockWidget( - self.dock_manager, "Available Devices", self - ) - self.available_devices_dock.setWidget(self.available_devices) + # # Available Resources Widget + # self.available_devices = AvailableDeviceResources( + # self, shared_selection_signal=self._shared_selection + # ) + # self.available_devices_dock = QtAds.CDockWidget( + # self.dock_manager, "Available Devices", self + # ) + # self.available_devices_dock.setWidget(self.available_devices) # Device Table View widget self.device_table_view = DeviceTableView( @@ -130,28 +190,61 @@ def __init__(self, parent=None, *args, **kwargs): self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self) self.ophyd_test_dock_view.setWidget(self.ophyd_test_view) - # Arrange widgets within the QtAds dock manager + # Help Inspector + widget = QWidget(self) + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.help_inspector = HelpInspector(self) + layout.addWidget(self.help_inspector) + text_box = QTextEdit(self) + text_box.setReadOnly(False) + text_box.setPlaceholderText("Help text will appear here...") + layout.addWidget(text_box) + self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self) + self.help_inspector_dock.setWidget(widget) + + # # Hook inspector signals + # def _class_cb(text: str): + # print(text) + + # # Register callback + self.help_inspector.bec_widget_help.connect(text_box.setMarkdown) + + # Error Logs View + self.error_logs_view = QTextEdit(self) + self.error_logs_view.setReadOnly(True) + self.error_logs_view.setPlaceholderText("Error logs will appear here...") + self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self) + self.error_logs_dock.setWidget(self.error_logs_view) + self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown) + # Arrange widgets within the QtAds dock manager # Central widget area self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock) + # Right area - should be pushed into view if something is active self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.BottomDockWidgetArea, - self.dm_docs_view_dock, + QtAds.DockWidgetArea.RightDockWidgetArea, + self.ophyd_test_dock_view, self.central_dock_area, ) - - # Left Area - self.left_dock_area = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock + # create bottom area (2-arg -> area) + self.bottom_dock_area = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock ) + + # YAML view left of docstrings (docks relative to bottom area) self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_config_view_dock, self.left_dock_area + QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area ) - # Right area - self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.RightDockWidgetArea, self.ophyd_test_dock_view + # Error/help area right of docstrings (dock relative to bottom area) + area = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, + self.help_inspector_dock, + self.bottom_dock_area, ) + self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area) for dock in self.dock_manager.dockWidgets(): # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea @@ -160,13 +253,16 @@ def __init__(self, parent=None, *args, **kwargs): dock.setFeature(CDockWidget.DockWidgetFloatable, False) dock.setFeature(CDockWidget.DockWidgetMovable, False) + # TODO decide if we like to hide the title bars.. # Fetch all dock areas of the dock widgets (on our case always one dock area) - for dock in self.dock_manager.dockWidgets(): - area = dock.dockAreaWidget() - area.titleBar().setVisible(False) + # for dock in self.dock_manager.dockWidgets(): + # if dock.objectName() in ["Help Inspector", "Error Logs"]: + # continue + # area = dock.dockAreaWidget() + # area.titleBar().setVisible(False) # Apply stretch after the layout is done - self.set_default_view([2, 8, 2], [3, 1]) + self.set_default_view([2, 8, 2], [7, 3]) # self.set_default_view([2, 8, 2], [2, 2, 4]) # Connect slots @@ -175,29 +271,29 @@ def __init__(self, parent=None, *args, **kwargs): self.device_table_view.selected_devices, (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), ), - ( - self.available_devices.selected_devices, - (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), - ), + # ( + # self.available_devices.selected_devices, + # (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + # ), ( self.ophyd_test_view.device_validated, (self.device_table_view.update_device_validation,), ), - ( - self.device_table_view.device_configs_changed, - ( - self.ophyd_test_view.change_device_configs, - self.available_devices.mark_devices_used, - ), - ), - ( - self.available_devices.add_selected_devices, - (self.device_table_view.add_device_configs,), - ), - ( - self.available_devices.del_selected_devices, - (self.device_table_view.remove_device_configs,), - ), + # ( + # self.device_table_view.device_configs_changed, + # ( + # self.ophyd_test_view.change_device_configs, + # self.available_devices.mark_devices_used, + # ), + # ), + # ( + # self.available_devices.add_selected_devices, + # (self.device_table_view.add_device_configs,), + # ), + # ( + # self.available_devices.del_selected_devices, + # (self.device_table_view.remove_device_configs,), + # ), ]: for slot in slots: signal.connect(slot) @@ -217,7 +313,9 @@ def _add_io_actions(self): # Create IO bundle io_bundle = ToolbarBundle("IO", self.toolbar.components) + # Load from disk load = MaterialIconAction( + text_position="under", icon_name="file_open", parent=self, tooltip="Load configuration file from disk", @@ -229,6 +327,7 @@ def _add_io_actions(self): # Add safe to disk safe_to_disk = MaterialIconAction( + text_position="under", icon_name="file_save", parent=self, tooltip="Save config to disk", @@ -240,10 +339,11 @@ def _add_io_actions(self): # Add load config from redis load_redis = MaterialIconAction( + text_position="under", icon_name="cached", parent=self, tooltip="Load current config from Redis", - label_text="Reload Config", + label_text="Get Current Config", ) load_redis.action.triggered.connect(self._load_redis_action) self.toolbar.components.add_safe("load_redis", load_redis) @@ -251,11 +351,13 @@ def _add_io_actions(self): # Update config action update_config_redis = MaterialIconAction( + text_position="under", icon_name="cloud_upload", parent=self, tooltip="Update current config in Redis", label_text="Update Config", ) + update_config_redis.action.setEnabled(False) update_config_redis.action.triggered.connect(self._update_redis_action) self.toolbar.components.add_safe("update_config_redis", update_config_redis) io_bundle.add_action("update_config_redis") @@ -270,6 +372,7 @@ def _add_table_actions(self) -> None: # Reset composed view reset_composed = MaterialIconAction( + text_position="under", icon_name="delete_sweep", parent=self, tooltip="Reset current composed config view", @@ -281,7 +384,11 @@ def _add_table_actions(self) -> None: # Add device add_device = MaterialIconAction( - icon_name="add", parent=self, tooltip="Add new device", label_text="Add Device" + text_position="under", + icon_name="add", + parent=self, + tooltip="Add new device", + label_text="Add Device", ) add_device.action.triggered.connect(self._add_device_action) self.toolbar.components.add_safe("add_device", add_device) @@ -289,7 +396,11 @@ def _add_table_actions(self) -> None: # Remove device remove_device = MaterialIconAction( - icon_name="remove", parent=self, tooltip="Remove device", label_text="Remove Device" + text_position="under", + icon_name="remove", + parent=self, + tooltip="Remove device", + label_text="Remove Device", ) remove_device.action.triggered.connect(self._remove_device_action) self.toolbar.components.add_safe("remove_device", remove_device) @@ -297,10 +408,11 @@ def _add_table_actions(self) -> None: # Rerun validation rerun_validation = MaterialIconAction( + text_position="under", icon_name="checklist", parent=self, tooltip="Run device validation with 'connect' on selected devices", - label_text="Rerun Validation", + label_text="Validate Connection", ) rerun_validation.action.triggered.connect(self._rerun_validation_action) self.toolbar.components.add_safe("rerun_validation", rerun_validation) @@ -346,15 +458,26 @@ def _load_file_action(self): file_path, _ = QFileDialog.getOpenFileName( self, caption="Select Config File", dir=start_dir ) - if file_path: - try: - config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] - except Exception as e: - logger.error(f"Failed to load config from file {file_path}. Error: {e}") - return - self.device_table_view.set_device_config( - config - ) # TODO ADD QDialog with 'replace', 'add' & 'cancel' + self._load_config_from_file(file_path) + + def _load_config_from_file(self, file_path: str): + """ + Load device config from a given file path and update the device table view. + + Args: + file_path (str): Path to the configuration file. + """ + try: + config = [{"name": k, **v} for k, v in yaml_load(file_path).items()] + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + dialog = ConfigChoiceDialog(self) + if dialog.exec(): + if dialog.result() == ConfigChoiceDialog.REPLACE: + self.device_table_view.set_device_config(config) + elif dialog.result() == ConfigChoiceDialog.ADD: + self.device_table_view.add_device_configs(config) # TODO would we ever like to add the current config to an existing composition @SafeSlot() diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 02c4d6076..ef397d03c 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -192,6 +192,7 @@ def get_help_md(self) -> str: Returns: str: The help text in markdown format. """ + return "" @SafeSlot() @SafeSlot(str) diff --git a/bec_widgets/utils/help_inspector/help_inspector.py b/bec_widgets/utils/help_inspector/help_inspector.py index e9976945e..db561a9aa 100644 --- a/bec_widgets/utils/help_inspector/help_inspector.py +++ b/bec_widgets/utils/help_inspector/help_inspector.py @@ -11,6 +11,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import AccentColors, get_accent_colors from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.widget_io import WidgetHierarchy logger = bec_logger.logger @@ -100,7 +101,7 @@ def _toggle_mode(self, enabled: bool): self._button.setChecked(False) QtWidgets.QApplication.restoreOverrideCursor() - def eventFilter(self, obj, event): + def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool: """ Filter events to capture Key_Escape event, and mouse clicks if event filter is active. Any click event on a widget is suppressed, if @@ -111,25 +112,32 @@ def eventFilter(self, obj, event): obj (QObject): The object that received the event. event (QEvent): The event to filter. """ - if ( - event.type() == QtCore.QEvent.KeyPress - and event.key() == QtCore.Qt.Key_Escape - and self._active - ): + # If not active, return immediately + if not self._active: + return super().eventFilter(obj, event) + # If active, handle escape key + if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape: self._toggle_mode(False) return super().eventFilter(obj, event) - if self._active and event.type() == QtCore.QEvent.MouseButtonPress: + # If active, and left mouse button pressed, handle click + if event.type() == QtCore.QEvent.MouseButtonPress: if event.button() == QtCore.Qt.LeftButton: widget = self._app.widgetAt(event.globalPos()) + if widget is None: + return super().eventFilter(obj, event) + # Get BECWidget ancestor + # TODO check what happens if the HELP Inspector itself is embedded in another BECWidget + # I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one + widget = WidgetHierarchy._get_becwidget_ancestor(widget) if widget: - if widget is self or self.isAncestorOf(widget): + if widget is self: self._toggle_mode(False) return True for cb in self._callbacks.values(): try: cb(widget) except Exception as e: - print(f"Error occurred in callback {cb}: {e}") + logger.error(f"Error occurred in callback {cb}: {e}") return True return super().eventFilter(obj, event) diff --git a/bec_widgets/widgets/control/device_manager/components/constants.py b/bec_widgets/widgets/control/device_manager/components/constants.py index b438470e3..21035c4c8 100644 --- a/bec_widgets/widgets/control/device_manager/components/constants.py +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -6,3 +6,58 @@ # Custom user roles SORT_KEY_ROLE: Final[int] = 117 CONFIG_DATA_ROLE: Final[int] = 118 + +# TODO 882 keep in sync with headers in device_table_view.py +HEADERS_HELP_MD: dict[str, str] = { + "status": ( + "## Status" + "\n" + "The current status of the device. Can be one of the following values: \n" + "### **LOADED** \n The device with the specified configuration is loaded in the current config.\n" + "### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.\n" + "### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.\n" + "### **VALID** \n The device config is valid, but the connection has not yet been validated.\n" + "### **INVALID** \n The device config is invalid and can not be loaded to the current config.\n" + ), + "name": ("## Name " "\n" "The name of the device."), + "deviceClass": ( + "## Device Class" + "\n" + "The device class specifies the type of the device. It will be used to create the instance." + ), + "readoutPriority": ( + "## Readout Priority" + "\n" + "The readout priority of the device. Can be one of the following values: \n" + "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.\n" + "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.\n" + "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.\n" + "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.\n" + "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.\n" + ), + "deviceTags": ( + "## Device Tags" + "\n" + "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager." + ), + "enabled": ( + "## Enabled" + "\n" + "Indicator whether the device is enabled or disabled. Disabled devices can not be used." + ), + "readOnly": ("## Read Only" "\n" "Indicator that a device is read-only or can be modified."), + "onFailure": ( + "## On Failure" + "\n" + "Specifies the behavior of the device in case of a failure. Can be one of the following values: \n" + "### **buffer** \n The device readback will fall back to the last known value.\n" + "### **retry** \n The device readback will be retried once, and raises an error if it fails again.\n" + "### **raise** \n The device readback will raise immediately.\n" + ), + "softwareTrigger": ( + "## Software Trigger" + "\n" + "Indicator whether the device receives a software trigger from BEC during a scan." + ), + "description": ("## Description" "\n" "A short description of the device."), +} diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 4a92e8416..17c05fe22 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -4,11 +4,13 @@ import copy import json +import textwrap from contextlib import contextmanager from functools import partial from typing import TYPE_CHECKING, Any, Iterable, List from uuid import uuid4 +from bec_lib.atlas_models import Device from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtGui, QtWidgets @@ -21,7 +23,10 @@ from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal -from bec_widgets.widgets.control.device_manager.components.constants import MIME_DEVICE_CONFIG +from bec_widgets.widgets.control.device_manager.components.constants import ( + HEADERS_HELP_MD, + MIME_DEVICE_CONFIG, +) from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus if TYPE_CHECKING: # pragma: no cover @@ -75,6 +80,111 @@ def paint(self, painter, option, index) -> None: painter.restore() +class WrappingTextDelegate(CustomDisplayDelegate): + """A lightweight delegate that wraps text without expensive size recalculation.""" + + def __init__(self, parent=None, max_width=300, margin=6): + super().__init__(parent) + self._parent = parent + self.max_width = max_width + self.margin = margin + self._cache = {} # cache text metrics for performance + + def _do_custom_paint(self, painter, option, index, value: str): + text = str(value) + if not text: + return + painter.save() + painter.setClipRect(option.rect) + + # Use cached layout if available + cache_key = (text, option.rect.width()) + layout = self._cache.get(cache_key) + if layout is None: + layout = QtGui.QTextLayout(text, option.font) + layout.beginLayout() + height = 0 + while True: + line = layout.createLine() + if not line.isValid(): + break + line.setLineWidth(option.rect.width() - self.margin) + line.setPosition(QtCore.QPointF(self.margin / 2, height)) + height += line.height() + layout.endLayout() + self._cache[cache_key] = layout + + # # Draw background if selected + # if option.state & QtWidgets.QStyle.State_Selected: + # painter.fillRect(option.rect, option.palette.highlight()) + + # Draw text + painter.setPen(option.palette.text().color()) + layout.draw(painter, option.rect.topLeft()) + + painter.restore() + + def sizeHint(self, option, index): + """Return a cached or approximate height; avoids costly recomputation.""" + text = str(index.data(QtCore.Qt.DisplayRole) or "") + view = self._parent + view.initViewItemOption(option) + if view.isColumnHidden(index.column()) or not view.isVisible() or not text: + return QtCore.QSize(0, option.fontMetrics.height() + 2 * self.margin) + + # Use cache for consistent size computation + cache_key = (text, self.max_width) + if cache_key in self._cache: + layout = self._cache[cache_key] + height = 0 + for i in range(layout.lineCount()): + height += layout.lineAt(i).height() + return QtCore.QSize(self.max_width, int(height + self.margin)) + + # Approximate without layout (fast path) + metrics = option.fontMetrics + pixel_width = max(self._parent.columnWidth(index.column()), 100) + if pixel_width > 2000: # safeguard against uninitialized columns, may return large values + pixel_width = 100 + char_per_line = self.estimate_chars_per_line(text, option, pixel_width - 2 * self.margin) + wrapped_lines = textwrap.wrap(text, width=char_per_line) + lines = len(wrapped_lines) + return QtCore.QSize(pixel_width, lines * (metrics.height()) + 2 * self.margin) + + def estimate_chars_per_line(self, text: str, option, column_width: int) -> int: + """Estimate number of characters that fit in a line for given width.""" + metrics = option.fontMetrics + elided = metrics.elidedText(text, Qt.ElideRight, column_width) + return len(elided.rstrip("…")) + + @SafeSlot(int, int, int) + def _on_section_resized(self, logical_index, old_size=None, new_size=None): + """Only update rows if a wrapped column was resized.""" + self._cache.clear() + self._update_row_heights() + + def _update_row_heights(self): + """Efficiently adjust row heights based on wrapped columns.""" + view = self._parent + proxy = view.model() + model = proxy.sourceModel() + option = QtWidgets.QStyleOptionViewItem() + view.initViewItemOption(option) + # wrapping delegates + wrap_delegate_columns = [] + for row in range(proxy.rowCount()): + max_height = 18 + for column in [5, 6]: # TODO 884 don't hardcode columns.. to be improved + index = proxy.index(row, column) + # model_index = proxy.mapToSource(index) + # delegate = view.itemDelegateForColumn(model_index) or view.itemDelegate() + delegate = view.itemDelegateForColumn(column) + hint = delegate.sizeHint(option, index) + max_height = max(max_height, hint.height()) + if view.rowHeight(row) != max_height: + view.setRowHeight(row, max_height) + + class CenterCheckBoxDelegate(CustomDisplayDelegate): """Custom checkbox delegate to center checkboxes in table cells.""" @@ -89,8 +199,9 @@ def __init__(self, parent=None, colors=None): def apply_theme(self, theme: str | None = None): colors = get_accent_colors() - self._icon_checked.setColor(colors.default) - self._icon_unchecked.setColor(colors.default) + _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) + self._icon_checked = _icon("check_box") + self._icon_unchecked = _icon("check_box_outline_blank") def _do_custom_paint(self, painter, option, index, value): pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked @@ -123,8 +234,12 @@ def __init__(self, parent=None, colors=None): def apply_theme(self, theme: str | None = None): colors = get_accent_colors() - for status, icon in self._icons.items(): - icon.setColor(colors[status]) + _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) + self._icons = { + ValidationStatus.PENDING: _icon(color=colors.default), + ValidationStatus.VALID: _icon(color=colors.success), + ValidationStatus.FAILED: _icon(color=colors.emergency), + } def _do_custom_paint(self, painter, option, index, value): if pixmap := self._icons.get(value): @@ -147,16 +262,21 @@ def __init__(self, parent=None): super().__init__(parent) self._device_config: list[dict[str, Any]] = [] self._validation_status: dict[str, ValidationStatus] = {} + # TODO 882 keep in sync with HEADERS_HELP_MD self.headers = [ - "", + "status", "name", "deviceClass", "readoutPriority", + "onFailure", "deviceTags", + "description", "enabled", "readOnly", + "softwareTrigger", ] self._checkable_columns_enabled = {"enabled": True, "readOnly": True} + self._device_model_schema = Device.model_json_schema() ############################################### ########## Override custom Qt methods ######### @@ -172,6 +292,8 @@ def columnCount( def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)): if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: + if section == 9: # softwareTrigger + return "softTrig" return self.headers[section] return None @@ -192,20 +314,24 @@ def data(self, index, role=int(Qt.ItemDataRole.DisplayRole)): return self._validation_status.get(dev_name, ValidationStatus.PENDING) key = self.headers[col] - value = self._device_config[row].get(key) + value = self._device_config[row].get(key, None) + if value is None: + value = ( + self._device_model_schema.get("properties", {}).get(key, {}).get("default", None) + ) if role == Qt.ItemDataRole.DisplayRole: - if key in ("enabled", "readOnly"): + if key in ("enabled", "readOnly", "softwareTrigger"): return bool(value) if key == "deviceTags": return ", ".join(str(tag) for tag in value) if value else "" if key == "deviceClass": return str(value).split(".")[-1] return str(value) if value is not None else "" - if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly"): + if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly", "softwareTrigger"): return Qt.CheckState.Checked if value else Qt.CheckState.Unchecked if role == Qt.ItemDataRole.TextAlignmentRole: - if key in ("enabled", "readOnly"): + if key in ("enabled", "readOnly", "softwareTrigger"): return Qt.AlignmentFlag.AlignCenter return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter if role == Qt.ItemDataRole.FontRole: @@ -223,7 +349,7 @@ def flags(self, index): Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled ) - if key in ("enabled", "readOnly"): + if key in ("enabled", "readOnly", "softwareTrigger"): if self._checkable_columns_enabled.get(key, True): return base_flags | Qt.ItemFlag.ItemIsUserCheckable else: @@ -245,7 +371,7 @@ def setData(self, index, value, role=int(Qt.ItemDataRole.EditRole)) -> bool: if not index.isValid(): return False key = self.headers[index.column()] - if key in ("enabled", "readOnly") and role == USER_CHECK_DATA_ROLE: + if key in ("enabled", "readOnly", "softwareTrigger") and role == USER_CHECK_DATA_ROLE: if not self._checkable_columns_enabled.get(key, True): return False # ignore changes if column is disabled self._device_config[index.row()][key] = value == Qt.CheckState.Checked @@ -299,9 +425,8 @@ def add_device_configs(self, device_configs: _DeviceCfgIter): added_configs = [] for cfg in device_configs: if self._name_exists_in_config(name := cfg.get("name", ""), True): - logger.warning(f"Device {name} already exists in the model.") - already_in_list.append(name) - continue + logger.warning(f"Device {name} is already in the config. It will be updated.") + self.remove_configs_by_name([name]) row = len(self._device_config) self.beginInsertRows(QtCore.QModelIndex(), row, row) self._device_config.append(copy.deepcopy(cfg)) @@ -488,7 +613,12 @@ def __init__(self, parent=None): self._hidden_rows = set() self._filter_text = "" self._enable_fuzzy = True - self._filter_columns = [1, 2] # name and deviceClass for search + self._filter_columns = [1, 2, 6] # name, deviceClass and description for search + self._status_order = { + ValidationStatus.VALID: 0, + ValidationStatus.PENDING: 1, + ValidationStatus.FAILED: 2, + } def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[dict[str, Any]]: return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows) @@ -506,6 +636,14 @@ def hide_rows(self, row_indices: list[int]): self._hidden_rows.update(row_indices) self.invalidateFilter() + def lessThan(self, left, right): + """Add custom sorting for the status column""" + if left.column() != 0 or right.column() != 0: + return super().lessThan(left, right) + left_data = self.sourceModel().data(left, Qt.ItemDataRole.DisplayRole) + right_data = self.sourceModel().data(right, Qt.ItemDataRole.DisplayRole) + return self._status_order.get(left_data, 99) < self._status_order.get(right_data, 99) + def show_rows(self, row_indices: list[int]): """ Show specific rows in the model. @@ -602,6 +740,21 @@ def __init__(self, parent=None, client=None, shared_selection_signal=SharedSelec # Connect signals self._model.configs_changed.connect(self.device_configs_changed.emit) + def get_help_md(self) -> str: + """ + Generate Markdown help for a cell or header. + """ + pos = self.table.mapFromGlobal(QtGui.QCursor.pos()) + model: DeviceTableModel = self._model # access underlying model + index = self.table.indexAt(pos) + if index.isValid(): + column = index.column() + label = model.headerData(column, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole) + if label == "softTrig": + label = "softwareTrigger" + return HEADERS_HELP_MD.get(label, "") + return "" + def _setup_search(self): """Create components related to the search functionality""" @@ -653,15 +806,20 @@ def _setup_table_view(self) -> None: self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors) self.tool_tip_delegate = DictToolTipDelegate(self.table) self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors) - self.table.setItemDelegateForColumn(0, self.validated_delegate) # ValidationStatus + self.wrapped_delegate = WrappingTextDelegate(self.table, max_width=300) + # Add resize handling for wrapped delegate + header = self.table.horizontalHeader() + + self.table.setItemDelegateForColumn(0, self.validated_delegate) # status self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority - self.table.setItemDelegateForColumn( - 4, self.tool_tip_delegate - ) # deviceTags (was wrap_delegate) - self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # enabled - self.table.setItemDelegateForColumn(6, self.checkbox_delegate) # readOnly + self.table.setItemDelegateForColumn(4, self.tool_tip_delegate) # onFailure + self.table.setItemDelegateForColumn(5, self.wrapped_delegate) # deviceTags + self.table.setItemDelegateForColumn(6, self.wrapped_delegate) # description + self.table.setItemDelegateForColumn(7, self.checkbox_delegate) # enabled + self.table.setItemDelegateForColumn(8, self.checkbox_delegate) # readOnly + self.table.setItemDelegateForColumn(9, self.checkbox_delegate) # softwareTrigger # Disable wrapping, use eliding, and smooth scrolling self.table.setWordWrap(False) @@ -675,19 +833,35 @@ def _setup_table_view(self) -> None: header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # name header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # deviceClass header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) # readoutPriority - header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # deviceTags: expand to fill - header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # enabled - header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # readOnly - - self.table.setColumnWidth(0, 25) - self.table.setColumnWidth(5, 70) - self.table.setColumnWidth(6, 70) + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) # onFailure + header.setSectionResizeMode( + 5, QHeaderView.ResizeMode.Interactive + ) # deviceTags: expand to fill + header.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) # descript: expand to fill + header.setSectionResizeMode(7, QHeaderView.ResizeMode.Fixed) # enabled + header.setSectionResizeMode(8, QHeaderView.ResizeMode.Fixed) # readOnly + header.setSectionResizeMode(9, QHeaderView.ResizeMode.Fixed) # softwareTrigger + + self.table.setColumnWidth(0, 70) + self.table.setColumnWidth(5, 200) + self.table.setColumnWidth(6, 200) + self.table.setColumnWidth(7, 70) + self.table.setColumnWidth(8, 70) + self.table.setColumnWidth(9, 70) # Ensure column widths stay fixed header.setMinimumSectionSize(25) header.setDefaultSectionSize(90) header.setStretchLastSection(False) + # Resize policy for wrapped text delegate + self._resize_proxy = BECSignalProxy( + header.sectionResized, + rateLimit=25, + slot=self.wrapped_delegate._on_section_resized, + timeout=1.0, + ) + # Selection behavior self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) diff --git a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py index fe40cfaa7..a73ada115 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -322,28 +322,36 @@ def _on_current_item_changed( self.validation_msg_md.emit("") def _format_markdown_text(self, device_name: str, raw_msg: str) -> str: - """Simple HTML formatting for validation messages, wrapping text naturally.""" - if not raw_msg.strip(): - return f"### Validation in progress for {device_name}... \n\n" - if raw_msg == "Validation in progress...": - return f"### Validation in progress for {device_name}... \n\n" + """ + Simple HTML formatting for validation messages, wrapping text naturally. - m = re.search(r"ERROR:\s*([^\s]+)\s+is not valid:\s*(.+?errors?)", raw_msg) - device, summary = m.group(1), m.group(2) - lines = [f"## Error for '{device}'", f"'{device}' is not valid: {summary}"] + Args: + device_name (str): The name of the device. + raw_msg (str): The raw validation message. + """ + if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...": + return f"### Validation in progress for {device_name}... \n\n" - # Find each field block: \n\n Field required ... - field_pat = re.compile( - r"\n(?P\w+)\n\s+(?PField required.*?(?=\n\w+\n|$))", re.DOTALL + # Regex to capture repeated ERROR patterns + pat = re.compile( + r"ERROR:\s*(?P[^\s]+)\s+" + r"(?Pis not valid|is not connectable|failed):\s*" + r"(?P.*?)(?=ERROR:|$)", + re.DOTALL, ) + blocks = [] + for m in pat.finditer(raw_msg): + dev = m.group("device") + status = m.group("status") + detail = m.group("detail").strip() + lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"] + blocks.append("\n\n".join(lines)) - for m in field_pat.finditer(raw_msg): - field = m.group("field") - rest = m.group("rest").rstrip() - lines.append(f"### {field}") - lines.append(rest) + # Fallback: If no patterns matched, return the raw message + if not blocks: + return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```" - return "\n".join(lines) + return "\n\n---\n\n".join(blocks) def validation_running(self): return self._device_list_items != {} @@ -386,7 +394,7 @@ def cleanup(self): layout.setSpacing(0) device_manager_ophyd_test = DMOphydTest() try: - config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml" + config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" config = [{"name": k, **v} for k, v in yaml_load(config_path).items()] except Exception as e: logger.error(f"Error loading config: {e}") From bc77d3199ea86b9435e878daa97713a37fdee523 Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 13 Oct 2025 07:47:03 +0200 Subject: [PATCH 096/161] refactor(device-manager-view): cleanup, minor bugfixes --- .../device_manager_view.py | 107 ++++++------ .../device_manager/components/constants.py | 111 +++++++------ .../components/device_table_view.py | 153 +++++++++++++----- .../components/dm_docstring_view.py | 2 +- 4 files changed, 220 insertions(+), 153 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 240f2a4fe..59e590d5e 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -143,6 +143,9 @@ def result(self): return self._result +AVAILABLE_RESOURCE_IS_READY = False + + class DeviceManagerView(BECWidget, QWidget): def __init__(self, parent=None, *args, **kwargs): @@ -159,15 +162,6 @@ def __init__(self, parent=None, *args, **kwargs): self.dock_manager.setStyleSheet("") self._root_layout.addWidget(self.dock_manager) - # # Available Resources Widget - # self.available_devices = AvailableDeviceResources( - # self, shared_selection_signal=self._shared_selection - # ) - # self.available_devices_dock = QtAds.CDockWidget( - # self.dock_manager, "Available Devices", self - # ) - # self.available_devices_dock.setWidget(self.available_devices) - # Device Table View widget self.device_table_view = DeviceTableView( self, shared_selection_signal=self._shared_selection @@ -204,11 +198,7 @@ def __init__(self, parent=None, *args, **kwargs): self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self) self.help_inspector_dock.setWidget(widget) - # # Hook inspector signals - # def _class_cb(text: str): - # print(text) - - # # Register callback + # Register callback self.help_inspector.bec_widget_help.connect(text_box.setMarkdown) # Error Logs View @@ -247,57 +237,63 @@ def __init__(self, parent=None, *args, **kwargs): self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area) for dock in self.dock_manager.dockWidgets(): - # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea - # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same dock.setFeature(CDockWidget.DockWidgetClosable, False) dock.setFeature(CDockWidget.DockWidgetFloatable, False) dock.setFeature(CDockWidget.DockWidgetMovable, False) - # TODO decide if we like to hide the title bars.. - # Fetch all dock areas of the dock widgets (on our case always one dock area) - # for dock in self.dock_manager.dockWidgets(): - # if dock.objectName() in ["Help Inspector", "Error Logs"]: - # continue - # area = dock.dockAreaWidget() - # area.titleBar().setVisible(False) - # Apply stretch after the layout is done self.set_default_view([2, 8, 2], [7, 3]) - # self.set_default_view([2, 8, 2], [2, 2, 4]) - # Connect slots for signal, slots in [ ( self.device_table_view.selected_devices, (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), ), - # ( - # self.available_devices.selected_devices, - # (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), - # ), ( self.ophyd_test_view.device_validated, (self.device_table_view.update_device_validation,), ), - # ( - # self.device_table_view.device_configs_changed, - # ( - # self.ophyd_test_view.change_device_configs, - # self.available_devices.mark_devices_used, - # ), - # ), - # ( - # self.available_devices.add_selected_devices, - # (self.device_table_view.add_device_configs,), - # ), - # ( - # self.available_devices.del_selected_devices, - # (self.device_table_view.remove_device_configs,), - # ), + ( + self.device_table_view.device_configs_changed, + (self.ophyd_test_view.change_device_configs,), + ), ]: for slot in slots: signal.connect(slot) + # Once available resource is ready, add it to the view again + if AVAILABLE_RESOURCE_IS_READY: + # Available Resources Widget + self.available_devices = AvailableDeviceResources( + self, shared_selection_signal=self._shared_selection + ) + self.available_devices_dock = QtAds.CDockWidget( + self.dock_manager, "Available Devices", self + ) + self.available_devices_dock.setWidget(self.available_devices) + # Connect slots for available reosource + for signal, slots in [ + ( + self.available_devices.selected_devices, + (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config), + ), + ( + self.device_table_view.device_configs_changed, + (self.available_devices.mark_devices_used,), + ), + ( + self.available_devices.add_selected_devices, + (self.device_table_view.add_device_configs,), + ), + ( + self.available_devices.del_selected_devices, + (self.device_table_view.remove_device_configs,), + ), + ]: + for slot in slots: + signal.connect(slot) + + # Add toolbar self._add_toolbar() def _add_toolbar(self): @@ -421,13 +417,6 @@ def _add_table_actions(self) -> None: # Add load config from plugin dir self.toolbar.add_bundle(table_bundle) - # Most likly, no actions on available devices - # Actions (vielleicht bundle fuer available devices ) - # - reset composed view - # - add new device (EpicsMotor, EpicsMotorECMC, EpicsSignal, CustomDevice) - # - remove device - # - rerun validation (with/without connect) - # IO actions def _coming_soon(self): return QMessageBox.question( @@ -441,7 +430,6 @@ def _coming_soon(self): @SafeSlot() def _load_file_action(self): """Action for the 'load' action to load a config from disk for the io_bundle of the toolbar.""" - # Check if plugin repo is installed... try: plugin_path = plugin_repo_path() plugin_name = plugin_package_name() @@ -542,7 +530,6 @@ def _save_to_disk_action(self): file.write(yaml.dump(config)) # Table actions - @SafeSlot() def _reset_composed_view(self): """Action for the 'reset_composed_view' action to reset the composed view.""" @@ -554,13 +541,10 @@ def _reset_composed_view(self): if reply == QMessageBox.StandardButton.Yes: self.device_table_view.clear_device_configs() - # TODO We want to have a combobox to choose from EpicsMotor, EpicsMotorECMC, EpicsSignal, EpicsSignalRO, and maybe EpicsSignalWithRBV and custom Device - # For all default Epics devices, we would like to preselect relevant fields, and prompt them with the proper deviceConfig args already, i.e. 'prefix', 'read_pv', 'write_pv' etc.. - # For custom Device, they should receive all options. It might be cool to get a side panel with docstring view of the class upon inspecting it to make it easier in case deviceConfig entries are required.. + # TODO Bespoke Form to add a new device @SafeSlot() def _add_device_action(self): """Action for the 'add_device' action to add a new device.""" - # Implement the logic to add a new device dialog = PresetClassDeviceConfigDialog(parent=self) dialog.accepted_data.connect(self._add_to_table_from_dialog) dialog.open() @@ -574,13 +558,12 @@ def _remove_device_action(self): """Action for the 'remove_device' action to remove a device.""" self.device_table_view.remove_selected_rows() - # TODO implement proper logic for validation. We should also carefully review how these jobs update the table, and how we can cancel pending validations - # in case they are no longer relevant. We might want to 'block' the interactivity on the items for which validation runs with 'connect'! @SafeSlot() - def _rerun_validation_action(self): + @SafeSlot(bool) + def _rerun_validation_action(self, connect: bool = True): """Action for the 'rerun_validation' action to rerun validation on selected devices.""" configs = self.device_table_view.table.selected_configs() - self.ophyd_test_view.change_device_configs(configs, True, True) + self.ophyd_test_view.change_device_configs(configs, True, connect) ####### Default view has to be done with setting up splitters ######## def set_default_view( diff --git a/bec_widgets/widgets/control/device_manager/components/constants.py b/bec_widgets/widgets/control/device_manager/components/constants.py index 21035c4c8..b3f720511 100644 --- a/bec_widgets/widgets/control/device_manager/components/constants.py +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -9,55 +9,64 @@ # TODO 882 keep in sync with headers in device_table_view.py HEADERS_HELP_MD: dict[str, str] = { - "status": ( - "## Status" - "\n" - "The current status of the device. Can be one of the following values: \n" - "### **LOADED** \n The device with the specified configuration is loaded in the current config.\n" - "### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.\n" - "### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.\n" - "### **VALID** \n The device config is valid, but the connection has not yet been validated.\n" - "### **INVALID** \n The device config is invalid and can not be loaded to the current config.\n" - ), - "name": ("## Name " "\n" "The name of the device."), - "deviceClass": ( - "## Device Class" - "\n" - "The device class specifies the type of the device. It will be used to create the instance." - ), - "readoutPriority": ( - "## Readout Priority" - "\n" - "The readout priority of the device. Can be one of the following values: \n" - "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.\n" - "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.\n" - "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.\n" - "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.\n" - "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.\n" - ), - "deviceTags": ( - "## Device Tags" - "\n" - "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager." - ), - "enabled": ( - "## Enabled" - "\n" - "Indicator whether the device is enabled or disabled. Disabled devices can not be used." - ), - "readOnly": ("## Read Only" "\n" "Indicator that a device is read-only or can be modified."), - "onFailure": ( - "## On Failure" - "\n" - "Specifies the behavior of the device in case of a failure. Can be one of the following values: \n" - "### **buffer** \n The device readback will fall back to the last known value.\n" - "### **retry** \n The device readback will be retried once, and raises an error if it fails again.\n" - "### **raise** \n The device readback will raise immediately.\n" - ), - "softwareTrigger": ( - "## Software Trigger" - "\n" - "Indicator whether the device receives a software trigger from BEC during a scan." - ), - "description": ("## Description" "\n" "A short description of the device."), + "status": "\n".join( + [ + "## Status", + "The current status of the device. Can be one of the following values: ", + "### **LOADED** \n The device with the specified configuration is loaded in the current config.", + "### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.", + "### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.", + "### **VALID** \n The device config is valid, but the connection has not yet been validated.", + "### **INVALID** \n The device config is invalid and can not be loaded to the current config.", + ] + ), + "name": "\n".join(["## Name ", "The name of the device."]), + "deviceClass": "\n".join( + [ + "## Device Class", + "The device class specifies the type of the device. It will be used to create the instance.", + ] + ), + "readoutPriority": "\n".join( + [ + "## Readout Priority", + "The readout priority of the device. Can be one of the following values: ", + "### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.", + "### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.", + "### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.", + "### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.", + "### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.", + ] + ), + "deviceTags": "\n".join( + [ + "## Device Tags", + "A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager.", + ] + ), + "enabled": "\n".join( + [ + "## Enabled", + "Indicator whether the device is enabled or disabled. Disabled devices can not be used.", + ] + ), + "readOnly": "\n".join( + ["## Read Only", "Indicator that a device is read-only or can be modified."] + ), + "onFailure": "\n".join( + [ + "## On Failure", + "Specifies the behavior of the device in case of a failure. Can be one of the following values: ", + "### **buffer** \n The device readback will fall back to the last known value.", + "### **retry** \n The device readback will be retried once, and raises an error if it fails again.", + "### **raise** \n The device readback will raise immediately.", + ] + ), + "softwareTrigger": "\n".join( + [ + "## Software Trigger", + "Indicator whether the device receives a software trigger from BEC during a scan.", + ] + ), + "description": "\n".join(["## Description", "A short description of the device."]), } diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 17c05fe22..9b8a7c07f 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -7,7 +7,7 @@ import textwrap from contextlib import contextmanager from functools import partial -from typing import TYPE_CHECKING, Any, Iterable, List +from typing import TYPE_CHECKING, Any, Iterable, List, Literal from uuid import uuid4 from bec_lib.atlas_models import Device @@ -46,7 +46,13 @@ class DictToolTipDelegate(QtWidgets.QStyledItemDelegate): """Delegate that shows all key-value pairs of a rows's data as a YAML-like tooltip.""" - def helpEvent(self, event, view, option, index): + def helpEvent( + self, + event: QtCore.QEvent, + view: QtWidgets.QAbstractItemView, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + ): """Override to show tooltip when hovering.""" if event.type() != QtCore.QEvent.Type.ToolTip: return super().helpEvent(event, view, option, index) @@ -64,13 +70,23 @@ class CustomDisplayDelegate(DictToolTipDelegate): def displayText(self, value: Any, locale: QtCore.QLocale | QtCore.QLocale.Language) -> str: return "" - def _test_custom_paint(self, painter, option, index): + def _test_custom_paint( + self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex + ): v = index.model().data(index, self._paint_test_role) return (v is not None), v - def _do_custom_paint(self, painter, option, index, value): ... + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Any, + ): ... - def paint(self, painter, option, index) -> None: + def paint( + self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex + ) -> None: (check, value) = self._test_custom_paint(painter, option, index) if not check: return super().paint(painter, option, index) @@ -83,14 +99,34 @@ def paint(self, painter, option, index) -> None: class WrappingTextDelegate(CustomDisplayDelegate): """A lightweight delegate that wraps text without expensive size recalculation.""" - def __init__(self, parent=None, max_width=300, margin=6): + def __init__(self, parent: BECTableView | None = None, max_width: int = 300, margin: int = 6): super().__init__(parent) self._parent = parent self.max_width = max_width self.margin = margin self._cache = {} # cache text metrics for performance - - def _do_custom_paint(self, painter, option, index, value: str): + self._wrapping_text_columns = None + + @property + def wrapping_text_columns(self) -> List[int]: + # Compute once, cache for later + if self._wrapping_text_columns is None: + self._wrapping_text_columns = [] + view = self._parent + proxy: DeviceFilterProxyModel = self._parent.model() + for col in range(proxy.columnCount()): + delegate = view.itemDelegateForColumn(col) + if isinstance(delegate, WrappingTextDelegate): + self._wrapping_text_columns.append(col) + return self._wrapping_text_columns + + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: str, + ): text = str(value) if not text: return @@ -101,30 +137,39 @@ def _do_custom_paint(self, painter, option, index, value: str): cache_key = (text, option.rect.width()) layout = self._cache.get(cache_key) if layout is None: - layout = QtGui.QTextLayout(text, option.font) - layout.beginLayout() - height = 0 - while True: - line = layout.createLine() - if not line.isValid(): - break - line.setLineWidth(option.rect.width() - self.margin) - line.setPosition(QtCore.QPointF(self.margin / 2, height)) - height += line.height() - layout.endLayout() + layout = self._compute_layout(text, option) self._cache[cache_key] = layout - # # Draw background if selected - # if option.state & QtWidgets.QStyle.State_Selected: - # painter.fillRect(option.rect, option.palette.highlight()) - # Draw text painter.setPen(option.palette.text().color()) layout.draw(painter, option.rect.topLeft()) - painter.restore() - def sizeHint(self, option, index): + def _compute_layout( + self, text: str, option: QtWidgets.QStyleOptionViewItem + ) -> QtGui.QTextLayout: + """Compute and return the text layout for given text and option.""" + layout = self._get_layout(text, option.font) + layout.beginLayout() + height = 0 + max_lines = 100 # safety cap, should never be more than 100 lines.. + for _ in range(max_lines): + line = layout.createLine() + if not line.isValid(): + break + line.setLineWidth(option.rect.width() - self.margin) + line.setPosition(QtCore.QPointF(self.margin / 2, height)) + line_height = line.height() + if line_height <= 0: + break # avoid negative or zero height lines to be added + height += line_height + layout.endLayout() + return layout + + def _get_layout(self, text: str, font_option: QtGui.QFont) -> QtGui.QTextLayout: + return QtGui.QTextLayout(text, font_option) + + def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> QtCore.QSize: """Return a cached or approximate height; avoids costly recomputation.""" text = str(index.data(QtCore.Qt.DisplayRole) or "") view = self._parent @@ -151,14 +196,19 @@ def sizeHint(self, option, index): lines = len(wrapped_lines) return QtCore.QSize(pixel_width, lines * (metrics.height()) + 2 * self.margin) - def estimate_chars_per_line(self, text: str, option, column_width: int) -> int: + def estimate_chars_per_line( + self, text: str, option: QtWidgets.QStyleOptionViewItem, column_width: int + ) -> int: """Estimate number of characters that fit in a line for given width.""" metrics = option.fontMetrics elided = metrics.elidedText(text, Qt.ElideRight, column_width) return len(elided.rstrip("…")) @SafeSlot(int, int, int) - def _on_section_resized(self, logical_index, old_size=None, new_size=None): + @SafeSlot(int) + def _on_section_resized( + self, logical_index: int, old_size: int | None = None, new_size: int | None = None + ): """Only update rows if a wrapped column was resized.""" self._cache.clear() self._update_row_heights() @@ -167,17 +217,12 @@ def _update_row_heights(self): """Efficiently adjust row heights based on wrapped columns.""" view = self._parent proxy = view.model() - model = proxy.sourceModel() option = QtWidgets.QStyleOptionViewItem() view.initViewItemOption(option) - # wrapping delegates - wrap_delegate_columns = [] for row in range(proxy.rowCount()): max_height = 18 - for column in [5, 6]: # TODO 884 don't hardcode columns.. to be improved + for column in self.wrapping_text_columns: index = proxy.index(row, column) - # model_index = proxy.mapToSource(index) - # delegate = view.itemDelegateForColumn(model_index) or view.itemDelegate() delegate = view.itemDelegateForColumn(column) hint = delegate.sizeHint(option, index) max_height = max(max_height, hint.height()) @@ -190,7 +235,7 @@ class CenterCheckBoxDelegate(CustomDisplayDelegate): _paint_test_role = USER_CHECK_DATA_ROLE - def __init__(self, parent=None, colors=None): + def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): super().__init__(parent) colors: AccentColors = colors if colors else get_accent_colors() # type: ignore _icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True) @@ -203,13 +248,27 @@ def apply_theme(self, theme: str | None = None): self._icon_checked = _icon("check_box") self._icon_unchecked = _icon("check_box_outline_blank") - def _do_custom_paint(self, painter, option, index, value): + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Literal[ + Qt.CheckState.Checked | Qt.CheckState.Unchecked | Qt.CheckState.PartiallyChecked + ], + ): pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked pix_rect = pixmap.rect() pix_rect.moveCenter(option.rect.center()) painter.drawPixmap(pix_rect.topLeft(), pixmap) - def editorEvent(self, event, model, option, index): + def editorEvent( + self, + event: QtCore.QEvent, + model: QtCore.QSortFilterProxyModel, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + ): if event.type() != QtCore.QEvent.Type.MouseButtonRelease: return False current = model.data(index, USER_CHECK_DATA_ROLE) @@ -222,7 +281,7 @@ def editorEvent(self, event, model, option, index): class DeviceValidatedDelegate(CustomDisplayDelegate): """Custom delegate for displaying validated device configurations.""" - def __init__(self, parent=None, colors=None): + def __init__(self, parent: BECTableView | None = None, colors: AccentColors | None = None): super().__init__(parent) colors = colors if colors else get_accent_colors() _icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True) @@ -241,7 +300,23 @@ def apply_theme(self, theme: str | None = None): ValidationStatus.FAILED: _icon(color=colors.emergency), } - def _do_custom_paint(self, painter, option, index, value): + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Literal[0, 1, 2], + ): + """ + Paint the validation status icon centered in the cell. + + Args: + painter (QtGui.QPainter): The painter object. + option (QtWidgets.QStyleOptionViewItem): The style options for the item. + index (QModelIndex): The model index of the item. + value (Literal[0,1,2]): The validation status value, where 0=Pending, 1=Valid, 2=Failed. + Relates to ValidationStatus enum. + """ if pixmap := self._icons.get(value): pix_rect = pixmap.rect() pix_rect.moveCenter(option.rect.center()) @@ -258,7 +333,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel): # tuple of list[dict[str, Any]] of configs which were added and bool True if added or False if removed configs_changed = QtCore.Signal(list, bool) - def __init__(self, parent=None): + def __init__(self, parent: DeviceTableModel | None = None): super().__init__(parent) self._device_config: list[dict[str, Any]] = [] self._validation_status: dict[str, ValidationStatus] = {} diff --git a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py index cd617d8fd..553462a00 100644 --- a/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -43,7 +43,7 @@ def docstring_to_markdown(obj) -> str: # Highlight section headers for Markdown headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"] for h in headers: - doc = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text) + text = re.sub(rf"(?m)^({h})\s*:?\s*$", rf"### \1", text) # Preserve code blocks (4+ space indented lines) def fence_code(match: re.Match) -> str: From b81e92b3ee6d5601c6e7bcbce4f50d4506a23fcf Mon Sep 17 00:00:00 2001 From: appel_c Date: Mon, 13 Oct 2025 07:47:36 +0200 Subject: [PATCH 097/161] test(device-manager-components): test for device manager components --- .../components/device_table_view.py | 38 +- .../test_device_manager_components.py | 869 ++++++++++++++++++ 2 files changed, 905 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/test_device_manager_components.py diff --git a/bec_widgets/widgets/control/device_manager/components/device_table_view.py b/bec_widgets/widgets/control/device_manager/components/device_table_view.py index 9b8a7c07f..886b02c78 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table_view.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table_view.py @@ -150,6 +150,9 @@ def _compute_layout( ) -> QtGui.QTextLayout: """Compute and return the text layout for given text and option.""" layout = self._get_layout(text, option.font) + text_option = QtGui.QTextOption() + text_option.setWrapMode(QtGui.QTextOption.WrapAnywhere) + layout.setTextOption(text_option) layout.beginLayout() height = 0 max_lines = 100 # safety cap, should never be more than 100 lines.. @@ -211,7 +214,8 @@ def _on_section_resized( ): """Only update rows if a wrapped column was resized.""" self._cache.clear() - self._update_row_heights() + # Make sure layout is computed first + QtCore.QTimer.singleShot(0, self._update_row_heights) def _update_row_heights(self): """Efficiently adjust row heights based on wrapped columns.""" @@ -662,7 +666,24 @@ def _confirm_and_remove_rows( """ configs = [model.get_row_data(r) for r in sorted(source_rows, key=lambda r: r.row())] names = [cfg.get("name", "") for cfg in configs] + if not names: + logger.warning("No device names found for selected rows.") + return False + if self._remove_rows_msg_dialog(names): + model.remove_device_configs(configs) + return True + return False + + def _remove_rows_msg_dialog(self, names: list[str]) -> bool: + """ + Prompt the user to confirm removal of rows and remove them from the model if accepted. + + Args: + names (list[str]): List of device names to be removed. + Returns: + bool: True if the user confirmed removal, False otherwise. + """ msg = QMessageBox(self) msg.setIcon(QMessageBox.Icon.Warning) msg.setWindowTitle("Confirm device removal") @@ -676,7 +697,6 @@ def _confirm_and_remove_rows( res = msg.exec_() if res == QMessageBox.StandardButton.Ok: - model.remove_device_configs(configs) return True return False @@ -946,6 +966,7 @@ def _setup_table_view(self) -> None: # Connect model signals to autosize request self._model.rowsInserted.connect(self._request_autosize_columns) + self._model.rowsRemoved.connect(self._request_autosize_columns) self._model.modelReset.connect(self._request_autosize_columns) self._model.dataChanged.connect(self._request_autosize_columns) @@ -1088,8 +1109,21 @@ def _button_clicked(): button.clicked.connect(_button_clicked) # pylint: disable=protected-access config = window.client.device_manager._get_redis_device_config() + config.insert( + 0, + { + "name": "TestDevice", + "deviceClass": "bec.devices.MockDevice", + "description": "Thisisaverylongsinglestringwhichisquiteannoyingmoreover, this is a test device with a very long description that should wrap around in the table view to test the wrapping functionality.", + "deviceTags": ["test", "mock", "longtagnameexample"], + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + }, + ) # names = [cfg.pop("name") for cfg in config] # config_dict = {name: cfg for name, cfg in zip(names, config)} window.set_device_config(config) + window.resize(1920, 1200) widget.show() sys.exit(app.exec_()) diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py new file mode 100644 index 000000000..b4454cfd7 --- /dev/null +++ b/tests/unit_tests/test_device_manager_components.py @@ -0,0 +1,869 @@ +"""Unit tests for device_manager_components module.""" + +from unittest import mock + +import pytest +import yaml +from bec_lib.atlas_models import Device as DeviceModel +from qtpy import QtCore, QtGui, QtWidgets + +from bec_widgets.widgets.control.device_manager.components.constants import HEADERS_HELP_MD +from bec_widgets.widgets.control.device_manager.components.device_table_view import ( + USER_CHECK_DATA_ROLE, + BECTableView, + CenterCheckBoxDelegate, + CustomDisplayDelegate, + DeviceFilterProxyModel, + DeviceTableModel, + DeviceTableView, + DeviceValidatedDelegate, + DictToolTipDelegate, + WrappingTextDelegate, +) +from bec_widgets.widgets.control.device_manager.components.dm_config_view import DMConfigView +from bec_widgets.widgets.control.device_manager.components.dm_docstring_view import ( + DocstringView, + docstring_to_markdown, +) +from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus + + +### Constants #### +def test_constants_headers_help_md(): + """Test that HEADERS_HELP_MD is a dictionary with expected keys and markdown format.""" + assert isinstance(HEADERS_HELP_MD, dict) + expected_keys = { + "status", + "name", + "deviceClass", + "readoutPriority", + "deviceTags", + "enabled", + "readOnly", + "onFailure", + "softwareTrigger", + "description", + } + assert set(HEADERS_HELP_MD.keys()) == expected_keys + for _, value in HEADERS_HELP_MD.items(): + assert isinstance(value, str) + assert value.startswith("## ") # Each entry should start with a markdown header + + +### DM Docstring View #### + + +@pytest.fixture +def docstring_view(qtbot): + """Fixture to create a DocstringView instance.""" + view = DocstringView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + +class NumPyStyleClass: + """Perform simple signal operations. + + Parameters + ---------- + data : numpy.ndarray + Input signal data. + + Attributes + ---------- + data : numpy.ndarray + The original signal data. + + Returns + ------- + SignalProcessor + An initialized signal processor instance. + """ + + +class GoogleStyleClass: + """Analyze spectral properties of a signal. + + Args: + frequencies (list[float]): Frequency bins. + amplitudes (list[float]): Corresponding amplitude values. + + Returns: + dict: A dictionary with spectral analysis results. + + Raises: + ValueError: If input lists are of unequal length. + """ + + +def test_docstring_view_docstring_to_markdown(): + """Test the docstring_to_markdown function with a sample class.""" + numpy_md = docstring_to_markdown(NumPyStyleClass) + assert "# NumPyStyleClass" in numpy_md + assert "### Parameters" in numpy_md + assert "### Attributes" in numpy_md + assert "### Returns" in numpy_md + assert "```" in numpy_md # Check for code block formatting + + google_md = docstring_to_markdown(GoogleStyleClass) + assert "# GoogleStyleClass" in google_md + assert "### Args" in google_md + assert "### Returns" in google_md + assert "### Raises" in google_md + assert "```" in google_md # Check for code block formatting + + +def test_docstring_view_on_select_config(docstring_view): + """Test the DocstringView on_select_config method. Called with single and multiple devices.""" + with ( + mock.patch.object(docstring_view, "set_device_class") as mock_set_device_class, + mock.patch.object(docstring_view, "_set_text") as mock_set_text, + ): + # Test with single device + docstring_view.on_select_config([{"deviceClass": "NumPyStyleClass"}]) + mock_set_device_class.assert_called_once_with("NumPyStyleClass") + + mock_set_device_class.reset_mock() + # Test with multiple devices, should not show anything + docstring_view.on_select_config( + [{"deviceClass": "NumPyStyleClass"}, {"deviceClass": "GoogleStyleClass"}] + ) + mock_set_device_class.assert_not_called() + mock_set_text.assert_called_once_with("") + + +def test_docstring_view_set_device_class(docstring_view): + """Test the DocstringView set_device_class method with valid and invalid class names.""" + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.get_plugin_class" + ) as mock_get_plugin_class: + + # Mock a valid class retrieval + mock_get_plugin_class.return_value = NumPyStyleClass + docstring_view.set_device_class("NumPyStyleClass") + assert "NumPyStyleClass" in docstring_view.toPlainText() + assert "Parameters" in docstring_view.toPlainText() + + # Mock an invalid class retrieval + mock_get_plugin_class.side_effect = ImportError("Class not found") + docstring_view.set_device_class("NonExistentClass") + assert "Error retrieving docstring for NonExistentClass" == docstring_view.toPlainText() + + # Test if READY_TO_VIEW is False + with mock.patch( + "bec_widgets.widgets.control.device_manager.components.dm_docstring_view.READY_TO_VIEW", + False, + ): + call_count = mock_get_plugin_class.call_count + docstring_view.set_device_class("NumPyStyleClass") # Should do nothing + assert mock_get_plugin_class.call_count == call_count # No new calls made + + +#### DM Config View #### + + +@pytest.fixture +def dm_config_view(qtbot): + """Fixture to create a DMConfigView instance.""" + view = DMConfigView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + +def test_dm_config_view_initialization(dm_config_view): + """Test DMConfigView proper initialization.""" + # Check that the stacked layout is set up correctly + assert dm_config_view.stacked_layout is not None + assert dm_config_view.stacked_layout.count() == 2 + # Assert Monaco editor is initialized + assert dm_config_view.monaco_editor.get_language() == "yaml" + assert dm_config_view.monaco_editor.editor._readonly is True + + # Check overlay widget + assert dm_config_view._overlay_widget is not None + assert dm_config_view._overlay_widget.text() == "Select single device to show config" + + # Check that overlay is initially shown + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + +def test_dm_config_view_on_select_config(dm_config_view): + """Test DMConfigView on_select_config with empty selection.""" + # Test with empty list of configs + dm_config_view.on_select_config([]) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + + # Test with a single config + cfgs = [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view.monaco_editor + text = yaml.dump(cfgs[0], default_flow_style=False) + assert text.strip("\n") == dm_config_view.monaco_editor.get_text().strip("\n") + + # Test with multiple configs + cfgs = 2 * [{"name": "test_device", "deviceClass": "TestClass", "enabled": True}] + dm_config_view.on_select_config(cfgs) + assert dm_config_view.stacked_layout.currentWidget() == dm_config_view._overlay_widget + assert dm_config_view.monaco_editor.get_text() == "" # Should remain unchanged + + +### Device Table View #### +# Not sure how to nicely test the delegates. + + +@pytest.fixture +def mock_table_view(qtbot): + """Create a mock table view for delegate testing.""" + table = BECTableView() + qtbot.addWidget(table) + qtbot.waitExposed(table) + yield table + + +@pytest.fixture +def device_table_model(qtbot, mock_table_view): + """Fixture to create a DeviceTableModel instance.""" + model = DeviceTableModel(mock_table_view) + yield model + + +@pytest.fixture +def device_proxy_model(qtbot, mock_table_view, device_table_model): + """Fixture to create a DeviceFilterProxyModel instance.""" + model = DeviceFilterProxyModel(mock_table_view) + model.setSourceModel(device_table_model) + mock_table_view.setModel(model) + yield model + + +@pytest.fixture +def qevent_mock() -> QtCore.QEvent: + """Create a mock QEvent for testing.""" + event = mock.MagicMock(spec=QtCore.QEvent) + yield event + + +@pytest.fixture +def view_mock() -> QtWidgets.QAbstractItemView: + """Create a mock QAbstractItemView for testing.""" + view = mock.MagicMock(spec=QtWidgets.QAbstractItemView) + yield view + + +@pytest.fixture +def index_mock(device_proxy_model) -> QtCore.QModelIndex: + """Create a mock QModelIndex for testing.""" + index = mock.MagicMock(spec=QtCore.QModelIndex) + index.model.return_value = device_proxy_model + yield index + + +@pytest.fixture +def option_mock() -> QtWidgets.QStyleOptionViewItem: + """Create a mock QStyleOptionViewItem for testing.""" + option = mock.MagicMock(spec=QtWidgets.QStyleOptionViewItem) + yield option + + +@pytest.fixture +def painter_mock() -> QtGui.QPainter: + """Create a mock QPainter for testing.""" + painter = mock.MagicMock(spec=QtGui.QPainter) + yield painter + + +def test_tooltip_delegate( + mock_table_view, qevent_mock, view_mock, option_mock, index_mock, device_proxy_model +): + """Test DictToolTipDelegate tooltip generation.""" + # No ToolTip event + delegate = DictToolTipDelegate(mock_table_view) + qevent_mock.type.return_value = QtCore.QEvent.Type.TouchCancel + # nothing should happen + with mock.patch.object( + QtWidgets.QStyledItemDelegate, "helpEvent", return_value=False + ) as super_mock: + result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) + + super_mock.assert_called_once_with(qevent_mock, view_mock, option_mock, index_mock) + assert result is False + + # ToolTip event + qevent_mock.type.return_value = QtCore.QEvent.Type.ToolTip + qevent_mock.globalPos = mock.MagicMock(return_value=QtCore.QPoint(10, 20)) + + source_model = device_proxy_model.sourceModel() + with ( + mock.patch.object( + source_model, "get_row_data", return_value={"description": "Mock description"} + ), + mock.patch.object(device_proxy_model, "mapToSource", return_value=index_mock), + mock.patch.object(QtWidgets.QToolTip, "showText") as show_text_mock, + ): + result = delegate.helpEvent(qevent_mock, view_mock, option_mock, index_mock) + show_text_mock.assert_called_once_with(QtCore.QPoint(10, 20), "Mock description", view_mock) + assert result is True + + +def test_custom_display_delegate(qtbot, mock_table_view, painter_mock, option_mock, index_mock): + """Test CustomDisplayDelegate initialization.""" + delegate = CustomDisplayDelegate(mock_table_view) + + # Test _test_custom_paint, with None and a value + def _return_data(): + yield None + yield "Test Value" + + proxy_model = index_mock.model() + with ( + mock.patch.object(proxy_model, "data", side_effect=_return_data()), + mock.patch.object( + QtWidgets.QStyledItemDelegate, "paint", return_value=None + ) as super_paint_mock, + mock.patch.object(delegate, "_do_custom_paint", return_value=None) as custom_paint_mock, + ): + delegate.paint(painter_mock, option_mock, index_mock) + super_paint_mock.assert_called_once_with(painter_mock, option_mock, index_mock) + custom_paint_mock.assert_not_called() + # Call again for the value case + delegate.paint(painter_mock, option_mock, index_mock) + super_paint_mock.assert_called_with(painter_mock, option_mock, index_mock) + assert super_paint_mock.call_count == 2 + custom_paint_mock.assert_called_once_with( + painter_mock, option_mock, index_mock, "Test Value" + ) + + +def test_center_checkbox_delegate( + mock_table_view, qevent_mock, painter_mock, option_mock, index_mock +): + """Test CenterCheckBoxDelegate initialization.""" + delegate = CenterCheckBoxDelegate(mock_table_view) + + option_mock.rect = QtCore.QRect(0, 0, 100, 20) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, QtCore.Qt.CheckState.Checked) + # Check that the checkbox is centered + pixrect = delegate._icon_checked.rect() + pixrect.moveCenter(option_mock.rect.center()) + painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), delegate._icon_checked) + + model = index_mock.model() + + # Editor event with non-check state role + qevent_mock.type.return_value = QtCore.QEvent.Type.MouseTrackingChange + assert not delegate.editorEvent(qevent_mock, model, option_mock, index_mock) + + # Editor event with check state role but not mouse button event + qevent_mock.type.return_value = QtCore.QEvent.Type.MouseButtonRelease + with ( + mock.patch.object(model, "data", return_value=QtCore.Qt.CheckState.Checked), + mock.patch.object(model, "setData") as mock_model_set, + ): + delegate.editorEvent(qevent_mock, model, option_mock, index_mock) + mock_model_set.assert_called_once_with( + index_mock, QtCore.Qt.CheckState.Unchecked, USER_CHECK_DATA_ROLE + ) + + +def test_device_validated_delegate( + mock_table_view, qevent_mock, painter_mock, option_mock, index_mock +): + """Test DeviceValidatedDelegate initialization.""" + # Invalid value + delegate = DeviceValidatedDelegate(mock_table_view) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, "wrong_value") + painter_mock.drawPixmap.assert_not_called() + + # Valid value + option_mock.rect = QtCore.QRect(0, 0, 100, 20) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID.value) + icon = delegate._icons[ValidationStatus.VALID.value] + pixrect = icon.rect() + pixrect.moveCenter(option_mock.rect.center()) + painter_mock.drawPixmap.assert_called_once_with(pixrect.topLeft(), icon) + + +def test_wrapping_text_delegate_do_custom_paint( + mock_table_view, painter_mock, option_mock, index_mock +): + """Test WrappingTextDelegate _do_custom_paint method.""" + delegate = WrappingTextDelegate(mock_table_view) + + # First case, empty text, nothing should happen + delegate._do_custom_paint(painter_mock, option_mock, index_mock, "") + painter_mock.setPen.assert_not_called() + layout_mock = mock.MagicMock() + + def _layout_comput_return(*args, **kwargs): + return layout_mock + + layout_mock.draw.return_value = None + with mock.patch.object(delegate, "_compute_layout", side_effect=_layout_comput_return): + delegate._do_custom_paint(painter_mock, option_mock, index_mock, "New Docstring") + layout_mock.draw.assert_called_with(painter_mock, option_mock.rect.topLeft()) + + +TEST_RECT_FOR = QtCore.QRect(0, 0, 100, 20) +TEST_TEXT_WITH_4_LINES = "This is a test string to check text wrapping in the delegate." + + +def test_wrapping_text_delegate_compute_layout(mock_table_view, option_mock): + """Test WrappingTextDelegate _compute_layout method.""" + delegate = WrappingTextDelegate(mock_table_view) + layout_mock = mock.MagicMock(spec=QtGui.QTextLayout) + + # This combination should yield 4 lines + with mock.patch.object(delegate, "_get_layout", return_value=layout_mock): + layout_mock.createLine.return_value = mock_line = mock.MagicMock(spec=QtGui.QTextLine) + mock_line.height.return_value = 10 + mock_line.isValid = mock.MagicMock(side_effect=[True, True, True, False]) + + option_mock.rect = TEST_RECT_FOR + option_mock.font = QtGui.QFont() + layout: QtGui.QTextLayout = delegate._compute_layout(TEST_TEXT_WITH_4_LINES, option_mock) + assert layout.createLine.call_count == 4 # pylint: disable=E1101 + assert mock_line.setPosition.call_count == 3 + assert mock_line.setPosition.call_args_list[-1] == mock.call( + QtCore.QPointF(delegate.margin / 2, 20) # 0, 10, 20 # Then false and exit + ) + + +def test_wrapping_text_delegate_size_hint(mock_table_view, option_mock, index_mock): + """Test WrappingTextDelegate sizeHint method. Use the test text that should wrap to 4 lines.""" + delegate = WrappingTextDelegate(mock_table_view) + assert delegate.margin == 6 + with ( + mock.patch.object(mock_table_view, "initViewItemOption"), + mock.patch.object(mock_table_view, "isColumnHidden", side_effect=[False, False]), + mock.patch.object(mock_table_view, "isVisible", side_effect=[True, True]), + ): + # Test with empty text, should return height + 2*margin + index_mock.data.return_value = "" + option_mock.rect = TEST_RECT_FOR + font_metrics = option_mock.fontMetrics = QtGui.QFontMetrics(QtGui.QFont()) + size = delegate.sizeHint(option_mock, index_mock) + assert size == QtCore.QSize(0, font_metrics.height() + 2 * delegate.margin) + + # Now test with the text that should wrap to 4 lines + index_mock.data.return_value = TEST_TEXT_WITH_4_LINES + size = delegate.sizeHint(option_mock, index_mock) + # The estimate goes to 5 lines + 2* margin + expected_lines = 5 + assert size == QtCore.QSize( + 100, font_metrics.height() * expected_lines + 2 * delegate.margin + ) + + +def test_wrapping_text_delegate_update_row_heights(mock_table_view, device_proxy_model): + """Test WrappingTextDelegate update_row_heights method.""" + device_cfg = DeviceModel( + name="test_device", deviceClass="TestClass", enabled=True, readoutPriority="baseline" + ).model_dump() + # Add single device to config + delegate = WrappingTextDelegate(mock_table_view) + row_heights = [25, 40] + + with mock.patch.object( + delegate, + "sizeHint", + side_effect=[QtCore.QSize(100, row_heights[0]), QtCore.QSize(100, row_heights[1])], + ): + mock_table_view.setItemDelegateForColumn(5, delegate) + mock_table_view.setItemDelegateForColumn(6, delegate) + device_proxy_model.sourceModel().set_device_config([device_cfg]) + assert delegate._wrapping_text_columns is None + assert mock_table_view.rowHeight(0) == 30 # Default height + delegate._update_row_heights() + assert delegate._wrapping_text_columns == [5, 6] + assert mock_table_view.rowHeight(0) == max(row_heights) + + +def test_device_validation_delegate( + mock_table_view, qevent_mock, painter_mock, option_mock, index_mock +): + """Test DeviceValidatedDelegate initialization.""" + delegate = DeviceValidatedDelegate(mock_table_view) + + option_mock.rect = QtCore.QRect(0, 0, 100, 20) + delegate._do_custom_paint(painter_mock, option_mock, index_mock, ValidationStatus.VALID) + # Check that the checkbox is centered + + pixrect = delegate._icons[ValidationStatus.VALID.value].rect() + pixrect.moveCenter(option_mock.rect.center()) + painter_mock.drawPixmap.assert_called_once_with( + pixrect.topLeft(), delegate._icons[ValidationStatus.VALID.value] + ) + + # Should not be called if invalid value + delegate._do_custom_paint(painter_mock, option_mock, index_mock, 10) + + # Check that the checkbox is centered + assert painter_mock.drawPixmap.call_count == 1 + + +### +# Test DeviceTableModel & DeviceFilterProxyModel +### + + +def test_device_table_model_data(device_proxy_model): + """Test the device table model data retrieval.""" + source_model = device_proxy_model.sourceModel() + test_device = { + "status": ValidationStatus.PENDING, + "name": "test_device", + "deviceClass": "TestClass", + "readoutPriority": "baseline", + "onFailure": "retry", + "enabled": True, + "readOnly": False, + "softwareTrigger": True, + "deviceTags": ["tag1", "tag2"], + "description": "Test device", + } + source_model.add_device_configs([test_device]) + assert source_model.rowCount() == 1 + assert source_model.columnCount() == 10 + + # Check data retrieval for each column + expected_data = { + 0: ValidationStatus.PENDING, # Default status + 1: "test_device", # name + 2: "TestClass", # deviceClass + 3: "baseline", # readoutPriority + 4: "retry", # onFailure + 5: "tag1, tag2", # deviceTags + 6: "Test device", # description + 7: True, # enabled + 8: False, # readOnly + 9: True, # softwareTrigger + } + + for col, expected in expected_data.items(): + index = source_model.index(0, col) + data = source_model.data(index, QtCore.Qt.DisplayRole) + assert data == expected + + +def test_device_table_model_with_data(device_table_model, device_proxy_model): + """Test (A): DeviceTableModel and DeviceFilterProxyModel with 3 rows of data.""" + # Create 3 test devices - names NOT alphabetically sorted + test_devices = [ + { + "name": "zebra_device", + "deviceClass": "TestClass1", + "enabled": True, + "readOnly": False, + "readoutPriority": "baseline", + "deviceTags": ["tag1", "tag2"], + "description": "Test device Z", + }, + { + "name": "alpha_device", + "deviceClass": "TestClass2", + "enabled": False, + "readOnly": True, + "readoutPriority": "primary", + "deviceTags": ["tag3"], + "description": "Test device A", + }, + { + "name": "beta_device", + "deviceClass": "TestClass3", + "enabled": True, + "readOnly": False, + "readoutPriority": "secondary", + "deviceTags": [], + "description": "Test device B", + }, + ] + + # Add devices to source model + device_table_model.add_device_configs(test_devices) + + # Check source model has 3 rows and proper columns + assert device_table_model.rowCount() == 3 + assert device_table_model.columnCount() == 10 + + # Check proxy model propagates the data + assert device_proxy_model.rowCount() == 3 + assert device_proxy_model.columnCount() == 10 + + # Verify data propagation through proxy - check names in original order + for i, expected_device in enumerate(test_devices): + proxy_index = device_proxy_model.index(i, 1) # Column 1 is name + source_index = device_proxy_model.mapToSource(proxy_index) + source_data = device_table_model.data(source_index, QtCore.Qt.DisplayRole) + assert source_data == expected_device["name"] + + # Check proxy data matches source + proxy_data = device_proxy_model.data(proxy_index, QtCore.Qt.DisplayRole) + assert proxy_data == source_data + + # Verify all columns are accessible + headers = device_table_model.headers + for col, header in enumerate(headers): + header_data = device_table_model.headerData( + col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole + ) + assert header_data is not None + + +def test_device_table_sorting(qtbot, mock_table_view, device_table_model, device_proxy_model): + """Test (B): Sorting functionality - original row 2 (alpha) should become row 0 after sort.""" + # Use same test data as above - zebra, alpha, beta (not alphabetically sorted) + test_devices = [ + { + "status": ValidationStatus.VALID, + "name": "zebra_device", + "deviceClass": "TestClass1", + "enabled": True, + }, + { + "status": ValidationStatus.PENDING, + "name": "alpha_device", + "deviceClass": "TestClass2", + "enabled": False, + }, + { + "status": ValidationStatus.FAILED, + "name": "beta_device", + "deviceClass": "TestClass3", + "enabled": True, + }, + ] + + device_table_model.add_device_configs(test_devices) + + # Verify initial order (unsorted) + assert ( + device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) + == "zebra_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) + == "alpha_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) + == "beta_device" + ) + + # Enable sorting and sort by name column (column 1) + mock_table_view.setSortingEnabled(True) + # header = mock_table_view.horizontalHeader() + # qtbot.mouseClick(header.sectionPosition(1), QtCore.Qt.LeftButton) + device_proxy_model.sort(1, QtCore.Qt.AscendingOrder) + + # After sorting, verify alphabetical order: alpha, beta, zebra + assert ( + device_proxy_model.data(device_proxy_model.index(0, 1), QtCore.Qt.DisplayRole) + == "alpha_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole) + == "beta_device" + ) + assert ( + device_proxy_model.data(device_proxy_model.index(2, 1), QtCore.Qt.DisplayRole) + == "zebra_device" + ) + + +def test_bec_table_view_remove_rows(qtbot, mock_table_view, device_table_model, device_proxy_model): + """Test (C): Remove rows from BECTableView and verify propagation.""" + # Set up test data + test_devices = [ + {"name": "device_to_keep", "deviceClass": "KeepClass", "enabled": True}, + {"name": "device_to_remove", "deviceClass": "RemoveClass", "enabled": False}, + {"name": "another_keeper", "deviceClass": "KeepClass2", "enabled": True}, + ] + + device_table_model.add_device_configs(test_devices) + assert device_table_model.rowCount() == 3 + assert device_proxy_model.rowCount() == 3 + + # Mock the confirmation dialog to first cancel, then confirm + with mock.patch.object( + mock_table_view, "_remove_rows_msg_dialog", side_effect=[False, True] + ) as mock_confirm: + + # Create mock selection for middle device (device_to_remove at row 1) + selection_model = mock.MagicMock() + proxy_index_to_remove = device_proxy_model.index(1, 0) # Row 1, any column + selection_model.selectedRows.return_value = [proxy_index_to_remove] + + mock_table_view.selectionModel = mock.MagicMock(return_value=selection_model) + + # Verify the device we're about to remove + device_name_to_remove = device_proxy_model.data( + device_proxy_model.index(1, 1), QtCore.Qt.DisplayRole + ) + assert device_name_to_remove == "device_to_remove" + + # Call delete_selected method + mock_table_view.delete_selected() + + # Verify confirmation was called + mock_confirm.assert_called_once() + + assert device_table_model.rowCount() == 3 # No change on first call + assert device_proxy_model.rowCount() == 3 + + # Call delete_selected again, this time it should confirm + mock_table_view.delete_selected() + + # Check that the device was removed from source model + assert device_table_model.rowCount() == 2 + assert device_proxy_model.rowCount() == 2 + + # Verify the remaining devices are correct + remaining_names = [] + for i in range(device_proxy_model.rowCount()): + name = device_proxy_model.data(device_proxy_model.index(i, 1), QtCore.Qt.DisplayRole) + remaining_names.append(name) + + assert "device_to_remove" not in remaining_names + + +def test_device_filter_proxy_model_filtering(device_table_model, device_proxy_model): + """Test DeviceFilterProxyModel text filtering functionality.""" + # Set up test data with different device names and classes + test_devices = [ + {"name": "motor_x", "deviceClass": "EpicsMotor", "description": "X-axis motor"}, + {"name": "detector_main", "deviceClass": "EpicsDetector", "description": "Main detector"}, + {"name": "motor_y", "deviceClass": "EpicsMotor", "description": "Y-axis motor"}, + ] + + device_table_model.add_device_configs(test_devices) + assert device_proxy_model.rowCount() == 3 + + # Test filtering by name + device_proxy_model.setFilterText("motor") + assert device_proxy_model.rowCount() == 2 + # Should show 2 rows (motor_x and motor_y) + visible_count = 0 + for i in range(device_proxy_model.rowCount()): + if not device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): + continue + visible_count += 1 + + # Test filtering by device class + device_proxy_model.setFilterText("EpicsDetector") + # Should show 1 row (detector_main) + detector_visible = False + assert device_proxy_model.rowCount() == 1 + for i in range(device_table_model.rowCount()): + if device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()): + source_index = device_table_model.index(i, 1) # Name column + name = device_table_model.data(source_index, QtCore.Qt.DisplayRole) + if name == "detector_main": + detector_visible = True + break + assert detector_visible + + # Clear filter + device_proxy_model.setFilterText("") + assert device_proxy_model.rowCount() == 3 + # Should show all 3 rows again + all_visible = all( + device_proxy_model.filterAcceptsRow(i, QtCore.QModelIndex()) + for i in range(device_table_model.rowCount()) + ) + assert all_visible + + +### +# Test DeviceTableView +### + + +@pytest.fixture +def device_table_view(qtbot): + """Fixture to create a DeviceTableView instance.""" + view = DeviceTableView() + qtbot.addWidget(view) + qtbot.waitExposed(view) + yield view + + +def test_device_table_view_initialization(qtbot, device_table_view): + """Test the DeviceTableView search method.""" + + # Check that the search input fields are properly initialized and connected + qtbot.keyClicks(device_table_view.search_input, "zebra") + qtbot.waitUntil(lambda: device_table_view.proxy._filter_text == "zebra", timeout=2000) + qtbot.mouseClick(device_table_view.fuzzy_is_disabled, QtCore.Qt.LeftButton) + qtbot.waitUntil(lambda: device_table_view.proxy._enable_fuzzy is True, timeout=2000) + + # Check table setup + + # header + header = device_table_view.table.horizontalHeader() + assert header.sectionResizeMode(5) == QtWidgets.QHeaderView.ResizeMode.Interactive # tags + assert header.sectionResizeMode(6) == QtWidgets.QHeaderView.ResizeMode.Stretch # description + + # table selection + assert ( + device_table_view.table.selectionBehavior() + == QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + assert ( + device_table_view.table.selectionMode() + == QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection + ) + + +def test_device_table_theme_update(device_table_view): + """Test DeviceTableView apply_theme method.""" + # Check apply theme propagates + with ( + mock.patch.object(device_table_view.checkbox_delegate, "apply_theme") as mock_apply, + mock.patch.object(device_table_view.validated_delegate, "apply_theme") as mock_validated, + ): + device_table_view.apply_theme("dark") + mock_apply.assert_called_once_with("dark") + mock_validated.assert_called_once_with("dark") + + +def test_device_table_view_updates(device_table_view): + """Test DeviceTableView methods that update the view and model.""" + # Test theme update triggered.. + + cfgs = [ + {"status": 0, "name": "test_device", "deviceClass": "TestClass", "enabled": True}, + {"status": 1, "name": "another_device", "deviceClass": "AnotherClass", "enabled": False}, + {"status": 2, "name": "zebra_device", "deviceClass": "ZebraClass", "enabled": True}, + ] + with mock.patch.object(device_table_view, "_request_autosize_columns") as mock_autosize: + # Should be called once for rowsInserted + device_table_view.set_device_config(cfgs) + assert device_table_view.get_device_config() == cfgs + mock_autosize.assert_called_once() + # Update validation status, should be called again + device_table_view.update_device_validation("test_device", ValidationStatus.VALID) + assert mock_autosize.call_count == 2 + # Remove a device, should triggere also a _request_autosize_columns call + device_table_view.remove_device_configs([cfgs[0]]) + assert device_table_view.get_device_config() == cfgs[1:] + assert mock_autosize.call_count == 3 + # Remove one device manually + device_table_view.remove_device("another_device") # Should remove the last device + assert device_table_view.get_device_config() == cfgs[2:] + assert mock_autosize.call_count == 4 + # Reset the model should call it once again + device_table_view.clear_device_configs() + assert mock_autosize.call_count == 5 + assert device_table_view.get_device_config() == [] + + +def test_device_table_view_get_help_md(device_table_view): + """Test DeviceTableView get_help_md method.""" + with mock.patch.object(device_table_view.table, "indexAt") as mock_index_at: + mock_index_at.isValid = mock.MagicMock(return_value=True) + with mock.patch.object(device_table_view, "_model") as mock_model: + mock_model.headerData = mock.MagicMock(side_effect=["softTrig"]) + # Second call is True, should return the corresponding help md + assert device_table_view.get_help_md() == HEADERS_HELP_MD["softwareTrigger"] From bb363d8280492b13f90413732d483a924d201d7e Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 14 Oct 2025 14:02:53 +0200 Subject: [PATCH 098/161] test(device-manager-view): Add test for the device-manager-view initialization --- .../device_manager_view.py | 14 +-- tests/unit_tests/test_device_manager_view.py | 92 +++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 tests/unit_tests/test_device_manager_view.py diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 59e590d5e..47f853662 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -322,16 +322,16 @@ def _add_io_actions(self): io_bundle.add_action("load") # Add safe to disk - safe_to_disk = MaterialIconAction( + save_to_disk = MaterialIconAction( text_position="under", icon_name="file_save", parent=self, tooltip="Save config to disk", label_text="Save Config", ) - self.toolbar.components.add_safe("safe_to_disk", safe_to_disk) - safe_to_disk.action.triggered.connect(self._save_to_disk_action) - io_bundle.add_action("safe_to_disk") + self.toolbar.components.add_safe("save_to_disk", save_to_disk) + save_to_disk.action.triggered.connect(self._save_to_disk_action) + io_bundle.add_action("save_to_disk") # Add load config from redis load_redis = MaterialIconAction( @@ -501,9 +501,9 @@ def _update_redis_action(self): return QMessageBox.warning( self, "Validation has not completed.", "Please wait for the validation to finish." ) - self._push_compositiion_to_redis() + self._push_composition_to_redis() - def _push_compositiion_to_redis(self): + def _push_composition_to_redis(self): config = {cfg.pop("name"): cfg for cfg in self.device_table_view.table.all_configs()} threadpool = QThreadPool.globalInstance() comm = CommunicateConfigAction(self._config_helper, None, config, "set") @@ -511,7 +511,7 @@ def _push_compositiion_to_redis(self): @SafeSlot() def _save_to_disk_action(self): - """Action for the 'safe_to_disk' action to save the current config to disk.""" + """Action for the 'save_to_disk' action to save the current config to disk.""" # Check if plugin repo is installed... try: config_path = self._get_recovery_config_path() diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py new file mode 100644 index 000000000..096cd6569 --- /dev/null +++ b/tests/unit_tests/test_device_manager_view.py @@ -0,0 +1,92 @@ +"""Unit tests for the device manager view""" + +from unittest import mock + +import pytest +from qtpy import QtCore + +from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.widgets.control.device_manager.components import ( + DeviceTableView, + DMConfigView, + DMOphydTest, + DocstringView, +) + + +@pytest.fixture +def dm_view(qtbot): + """Fixture for DeviceManagerView.""" + widget = DeviceManagerView() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_device_manager_view_initialization(dm_view): + """Test the basic layout of QtAds DockManager.""" + assert isinstance(dm_view.dock_manager.centralWidget().widget(), DeviceTableView) + assert any( + isinstance(dock.widget(), DMConfigView) for dock in dm_view.dock_manager.dockWidgets() + ) + assert any( + isinstance(dock.widget(), DMOphydTest) for dock in dm_view.dock_manager.dockWidgets() + ) + assert any( + isinstance(dock.widget(), DocstringView) for dock in dm_view.dock_manager.dockWidgets() + ) + + +def test_device_manager_view_toolbar_components(qtbot, dm_view): + """Test that the toolbar components exist for the device_manager_view.""" + # Load from disk action + for bundle_name in ["IO", "Table"]: + assert bundle_name in dm_view.toolbar.bundles + + # Load File action + assert dm_view.toolbar.components.exists("load") + with mock.patch.object(dm_view, "_load_file_action") as mock_load_action: + dm_view.toolbar.components._components["load"].action.action.triggered.emit() + mock_load_action.assert_called_once() + + # Save file action + assert dm_view.toolbar.components.exists("save_to_disk") + with mock.patch.object(dm_view, "_save_to_disk_action") as mock_save_action: + dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit() + mock_save_action.assert_called_once() + + # Load Redis action + assert dm_view.toolbar.components.exists("load_redis") + with mock.patch.object(dm_view, "_load_redis_action") as mock_load_redis: + dm_view.toolbar.components._components["load_redis"].action.action.triggered.emit() + mock_load_redis.assert_called_once() + + # Update Config + assert dm_view.toolbar.components.exists("update_config_redis") + with mock.patch.object(dm_view, "_update_redis_action") as mock_update_redis: + dm_view.toolbar.components._components["update_config_redis"].action.action.triggered.emit() + mock_update_redis.assert_called_once() + + # Reset Composed View + assert dm_view.toolbar.components.exists("reset_composed") + with mock.patch.object(dm_view, "_reset_composed_view") as mock_reset: + dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() + mock_reset.assert_called_once() + + # Add Device + assert dm_view.toolbar.components.exists("add_device") + with mock.patch.object(dm_view, "_add_device_action") as mock_add_device: + dm_view.toolbar.components._components["add_device"].action.action.triggered.emit() + mock_add_device.assert_called_once() + + # Remove Device + assert dm_view.toolbar.components.exists("remove_device") + with mock.patch.object(dm_view, "_remove_device_action") as mock_remove_device: + dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() + mock_remove_device.assert_called_once() + + # Rerun Validation + assert dm_view.toolbar.components.exists("rerun_validation") + with mock.patch.object(dm_view, "_rerun_validation_action") as mock_rerun: + dm_view.toolbar.components._components["rerun_validation"].action.action.triggered.emit() + mock_rerun.assert_called_once() From a57d3c6e1cd92f5c9545e1029a41d5299cfb1b24 Mon Sep 17 00:00:00 2001 From: appel_c Date: Tue, 14 Oct 2025 17:40:42 +0200 Subject: [PATCH 099/161] refactor: improve test coverage for help_inspector and device_manager_view --- .../device_manager_view.py | 35 ++- .../utils/help_inspector/help_inspector.py | 3 +- tests/unit_tests/test_device_manager_view.py | 266 +++++++++++++----- tests/unit_tests/test_help_inspector.py | 51 ++++ 4 files changed, 278 insertions(+), 77 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 47f853662..9acdb5a38 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -2,7 +2,7 @@ import os from functools import partial -from typing import List +from typing import List, Literal import PySide6QtAds as QtAds import yaml @@ -443,10 +443,20 @@ def _load_file_action(self): # Implement the file loading logic here start_dir = os.path.abspath(config_path) - file_path, _ = QFileDialog.getOpenFileName( - self, caption="Select Config File", dir=start_dir - ) - self._load_config_from_file(file_path) + file_path = self._get_file_path(start_dir, "open_file") + if file_path: + self._load_config_from_file(file_path) + + def _get_file_path(self, start_dir: str, mode: Literal["open_file", "save_file"]) -> str: + if mode == "open_file": + file_path, _ = QFileDialog.getOpenFileName( + self, caption="Select Config File", dir=start_dir + ) + else: + file_path, _ = QFileDialog.getSaveFileName( + self, caption="Save Config File", dir=start_dir + ) + return file_path def _load_config_from_file(self, file_path: str): """ @@ -460,6 +470,15 @@ def _load_config_from_file(self, file_path: str): except Exception as e: logger.error(f"Failed to load config from file {file_path}. Error: {e}") return + self._open_config_choice_dialog(config) + + def _open_config_choice_dialog(self, config: List[dict]): + """ + Open a dialog to choose whether to replace or add the loaded config. + + Args: + config (List[dict]): List of device configurations loaded from the file. + """ dialog = ConfigChoiceDialog(self) if dialog.exec(): if dialog.result() == ConfigChoiceDialog.REPLACE: @@ -484,7 +503,7 @@ def _load_redis_action(self): return @SafeSlot() - def _update_redis_action(self): + def _update_redis_action(self) -> None | QMessageBox.StandardButton: """Action to push the current composition to Redis""" reply = _yes_no_question( self, @@ -521,9 +540,7 @@ def _save_to_disk_action(self): logger.warning(f"Failed to find recovery config path, fallback to: {config_path}") # Implement the file loading logic here - file_path, _ = QFileDialog.getSaveFileName( - self, caption="Save Config File", dir=config_path - ) + file_path = self._get_file_path(config_path, "save_file") if file_path: config = {cfg.pop("name"): cfg for cfg in self.device_table_view.get_device_config()} with open(file_path, "w") as file: diff --git a/bec_widgets/utils/help_inspector/help_inspector.py b/bec_widgets/utils/help_inspector/help_inspector.py index db561a9aa..9a73cd34c 100644 --- a/bec_widgets/utils/help_inspector/help_inspector.py +++ b/bec_widgets/utils/help_inspector/help_inspector.py @@ -128,7 +128,8 @@ def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool: # Get BECWidget ancestor # TODO check what happens if the HELP Inspector itself is embedded in another BECWidget # I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one - widget = WidgetHierarchy._get_becwidget_ancestor(widget) + if not isinstance(widget, BECWidget): + widget = WidgetHierarchy._get_becwidget_ancestor(widget) if widget: if widget is self: self._toggle_mode(False) diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 096cd6569..a85be7319 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -1,11 +1,18 @@ """Unit tests for the device manager view""" +# pylint: disable=protected-access,redefined-outer-name + from unittest import mock import pytest from qtpy import QtCore +from qtpy.QtWidgets import QFileDialog, QMessageBox -from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView +from bec_widgets.applications.views.device_manager_view.device_manager_view import ( + ConfigChoiceDialog, + DeviceManagerView, +) +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector from bec_widgets.widgets.control.device_manager.components import ( DeviceTableView, DMConfigView, @@ -23,70 +30,195 @@ def dm_view(qtbot): yield widget -def test_device_manager_view_initialization(dm_view): - """Test the basic layout of QtAds DockManager.""" - assert isinstance(dm_view.dock_manager.centralWidget().widget(), DeviceTableView) - assert any( - isinstance(dock.widget(), DMConfigView) for dock in dm_view.dock_manager.dockWidgets() - ) - assert any( - isinstance(dock.widget(), DMOphydTest) for dock in dm_view.dock_manager.dockWidgets() - ) - assert any( - isinstance(dock.widget(), DocstringView) for dock in dm_view.dock_manager.dockWidgets() +@pytest.fixture +def config_choice_dialog(qtbot, dm_view): + """Fixture for ConfigChoiceDialog.""" + dialog = ConfigChoiceDialog(dm_view) + qtbot.addWidget(dialog) + qtbot.waitExposed(dialog) + yield dialog + + +def test_device_manager_view_config_choice_dialog(qtbot, dm_view, config_choice_dialog): + """Test the configuration choice dialog.""" + assert config_choice_dialog is not None + assert config_choice_dialog.parent() == dm_view + + # Test dialog components + with ( + mock.patch.object(config_choice_dialog, "accept") as mock_accept, + mock.patch.object(config_choice_dialog, "reject") as mock_reject, + ): + + # Replace + qtbot.mouseClick(config_choice_dialog.replace_btn, QtCore.Qt.LeftButton) + mock_accept.assert_called_once() + mock_reject.assert_not_called() + mock_accept.reset_mock() + assert config_choice_dialog.result() == config_choice_dialog.REPLACE + # Add + qtbot.mouseClick(config_choice_dialog.add_btn, QtCore.Qt.LeftButton) + mock_accept.assert_called_once() + mock_reject.assert_not_called() + mock_accept.reset_mock() + assert config_choice_dialog.result() == config_choice_dialog.ADD + # Cancel + qtbot.mouseClick(config_choice_dialog.cancel_btn, QtCore.Qt.LeftButton) + mock_accept.assert_not_called() + mock_reject.assert_called_once() + assert config_choice_dialog.result() == config_choice_dialog.CANCEL + + +class TestDeviceManagerViewInitialization: + """Test class for DeviceManagerView initialization and basic components.""" + + def test_dock_manager_initialization(self, dm_view): + """Test that the QtAds DockManager is properly initialized.""" + assert dm_view.dock_manager is not None + assert dm_view.dock_manager.centralWidget() is not None + + def test_central_widget_is_device_table_view(self, dm_view): + """Test that the central widget is DeviceTableView.""" + central_widget = dm_view.dock_manager.centralWidget().widget() + assert isinstance(central_widget, DeviceTableView) + assert central_widget is dm_view.device_table_view + + def test_dock_widgets_exist(self, dm_view): + """Test that all required dock widgets are created.""" + dock_widgets = dm_view.dock_manager.dockWidgets() + + # Check that we have the expected number of dock widgets + assert len(dock_widgets) >= 4 + + # Check for specific widget types + widget_types = [dock.widget().__class__ for dock in dock_widgets] + + assert DMConfigView in widget_types + assert DMOphydTest in widget_types + assert DocstringView in widget_types + + def test_toolbar_initialization(self, dm_view): + """Test that the toolbar is properly initialized with expected bundles.""" + assert dm_view.toolbar is not None + assert "IO" in dm_view.toolbar.bundles + assert "Table" in dm_view.toolbar.bundles + + def test_toolbar_components_exist(self, dm_view): + """Test that all expected toolbar components exist.""" + expected_components = [ + "load", + "save_to_disk", + "load_redis", + "update_config_redis", + "reset_composed", + "add_device", + "remove_device", + "rerun_validation", + ] + + for component in expected_components: + assert dm_view.toolbar.components.exists(component) + + def test_signal_connections(self, dm_view): + """Test that signals are properly connected between components.""" + # Test that device_table_view signals are connected + assert dm_view.device_table_view.selected_devices is not None + assert dm_view.device_table_view.device_configs_changed is not None + + # Test that ophyd_test_view signals are connected + assert dm_view.ophyd_test_view.device_validated is not None + + +class TestDeviceManagerViewIOBundle: + """Test class for DeviceManagerView IO bundle actions.""" + + def test_io_bundle_exists(self, dm_view): + """Test that IO bundle exists and contains expected actions.""" + assert "IO" in dm_view.toolbar.bundles + io_actions = ["load", "save_to_disk", "load_redis", "update_config_redis"] + for action in io_actions: + assert dm_view.toolbar.components.exists(action) + + def test_load_file_action_triggered(self, tmp_path, dm_view): + """Test load file action trigger mechanism.""" + + with ( + mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), + mock.patch( + "bec_widgets.applications.views.device_manager_view.device_manager_view.yaml_load" + ) as mock_yaml_load, + mock.patch.object(dm_view, "_open_config_choice_dialog") as mock_open_dialog, + ): + mock_yaml_data = {"device1": {"param1": "value1"}} + mock_yaml_load.return_value = mock_yaml_data + + # Setup dialog mock + dm_view.toolbar.components._components["load"].action.action.triggered.emit() + mock_yaml_load.assert_called_once_with(tmp_path) + mock_open_dialog.assert_called_once_with([{"name": "device1", "param1": "value1"}]) + + def test_save_config_to_file(self, tmp_path, dm_view): + """Test saving config to file.""" + yaml_path = tmp_path / "test_save.yaml" + mock_config = [{"name": "device1", "param1": "value1"}] + with ( + mock.patch.object(dm_view, "_get_file_path", return_value=tmp_path), + mock.patch.object(dm_view, "_get_recovery_config_path", return_value=tmp_path), + mock.patch.object(dm_view, "_get_file_path", return_value=yaml_path), + mock.patch.object( + dm_view.device_table_view, "get_device_config", return_value=mock_config + ), + ): + dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit() + assert yaml_path.exists() + + +class TestDeviceManagerViewTableBundle: + """Test class for DeviceManagerView Table bundle actions.""" + + def test_table_bundle_exists(self, dm_view): + """Test that Table bundle exists and contains expected actions.""" + assert "Table" in dm_view.toolbar.bundles + table_actions = ["reset_composed", "add_device", "remove_device", "rerun_validation"] + for action in table_actions: + assert dm_view.toolbar.components.exists(action) + + @mock.patch( + "bec_widgets.applications.views.device_manager_view.device_manager_view._yes_no_question" ) - - -def test_device_manager_view_toolbar_components(qtbot, dm_view): - """Test that the toolbar components exist for the device_manager_view.""" - # Load from disk action - for bundle_name in ["IO", "Table"]: - assert bundle_name in dm_view.toolbar.bundles - - # Load File action - assert dm_view.toolbar.components.exists("load") - with mock.patch.object(dm_view, "_load_file_action") as mock_load_action: - dm_view.toolbar.components._components["load"].action.action.triggered.emit() - mock_load_action.assert_called_once() - - # Save file action - assert dm_view.toolbar.components.exists("save_to_disk") - with mock.patch.object(dm_view, "_save_to_disk_action") as mock_save_action: - dm_view.toolbar.components._components["save_to_disk"].action.action.triggered.emit() - mock_save_action.assert_called_once() - - # Load Redis action - assert dm_view.toolbar.components.exists("load_redis") - with mock.patch.object(dm_view, "_load_redis_action") as mock_load_redis: - dm_view.toolbar.components._components["load_redis"].action.action.triggered.emit() - mock_load_redis.assert_called_once() - - # Update Config - assert dm_view.toolbar.components.exists("update_config_redis") - with mock.patch.object(dm_view, "_update_redis_action") as mock_update_redis: - dm_view.toolbar.components._components["update_config_redis"].action.action.triggered.emit() - mock_update_redis.assert_called_once() - - # Reset Composed View - assert dm_view.toolbar.components.exists("reset_composed") - with mock.patch.object(dm_view, "_reset_composed_view") as mock_reset: - dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() - mock_reset.assert_called_once() - - # Add Device - assert dm_view.toolbar.components.exists("add_device") - with mock.patch.object(dm_view, "_add_device_action") as mock_add_device: - dm_view.toolbar.components._components["add_device"].action.action.triggered.emit() - mock_add_device.assert_called_once() - - # Remove Device - assert dm_view.toolbar.components.exists("remove_device") - with mock.patch.object(dm_view, "_remove_device_action") as mock_remove_device: - dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() - mock_remove_device.assert_called_once() - - # Rerun Validation - assert dm_view.toolbar.components.exists("rerun_validation") - with mock.patch.object(dm_view, "_rerun_validation_action") as mock_rerun: - dm_view.toolbar.components._components["rerun_validation"].action.action.triggered.emit() - mock_rerun.assert_called_once() + def test_reset_composed_view(self, mock_question, dm_view): + """Test reset composed view when user confirms.""" + with mock.patch.object(dm_view.device_table_view, "clear_device_configs") as mock_clear: + mock_question.return_value = QMessageBox.StandardButton.Yes + dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() + mock_clear.assert_called_once() + mock_clear.reset_mock() + mock_question.return_value = QMessageBox.StandardButton.No + dm_view.toolbar.components._components["reset_composed"].action.action.triggered.emit() + mock_clear.assert_not_called() + + def test_add_device_action_connected(self, dm_view): + """Test add device action opens dialog correctly.""" + with mock.patch.object(dm_view, "_add_device_action") as mock_add: + dm_view.toolbar.components._components["add_device"].action.action.triggered.emit() + mock_add.assert_called_once() + + def test_remove_device_action(self, dm_view): + """Test remove device action.""" + with mock.patch.object(dm_view.device_table_view, "remove_selected_rows") as mock_remove: + dm_view.toolbar.components._components["remove_device"].action.action.triggered.emit() + mock_remove.assert_called_once() + + def test_rerun_device_validation(self, dm_view): + """Test rerun device validation action.""" + cfgs = [{"name": "device1", "param1": "value1"}] + with ( + mock.patch.object(dm_view.ophyd_test_view, "change_device_configs") as mock_change, + mock.patch.object( + dm_view.device_table_view.table, "selected_configs", return_value=cfgs + ), + ): + dm_view.toolbar.components._components[ + "rerun_validation" + ].action.action.triggered.emit() + mock_change.assert_called_once_with(cfgs, True, True) diff --git a/tests/unit_tests/test_help_inspector.py b/tests/unit_tests/test_help_inspector.py index 75cd738b6..5ab96274f 100644 --- a/tests/unit_tests/test_help_inspector.py +++ b/tests/unit_tests/test_help_inspector.py @@ -1,9 +1,12 @@ # pylint: disable=missing-function-docstring, missing-module-docstring, unused-import +from unittest import mock + import pytest from qtpy import QtCore, QtWidgets from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +from bec_widgets.utils.widget_io import WidgetHierarchy from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton from .client_mocks import mocked_client @@ -79,3 +82,51 @@ def test_help_inspector_escape_key(qtbot, help_inspector): assert not help_inspector._active assert not help_inspector._button.isChecked() assert QtWidgets.QApplication.overrideCursor() is None + + +def test_help_inspector_event_filter(help_inspector, abort_button): + """Test the event filter of the HelpInspector.""" + # Test nothing happens when not active + obj = mock.MagicMock(spec=QtWidgets.QWidget) + event = mock.MagicMock(spec=QtCore.QEvent) + assert help_inspector._active is False + with mock.patch.object( + QtWidgets.QWidget, "eventFilter", return_value=False + ) as super_event_filter: + help_inspector.eventFilter(obj, event) # should do nothing and return False + super_event_filter.assert_called_once_with(obj, event) + super_event_filter.reset_mock() + + help_inspector._active = True + with mock.patch.object(help_inspector, "_toggle_mode") as mock_toggle: + # Key press Escape + event.type = mock.MagicMock(return_value=QtCore.QEvent.KeyPress) + event.key = mock.MagicMock(return_value=QtCore.Qt.Key.Key_Escape) + help_inspector.eventFilter(obj, event) + mock_toggle.assert_called_once_with(False) + mock_toggle.reset_mock() + + # Click on itself + event.type = mock.MagicMock(return_value=QtCore.QEvent.MouseButtonPress) + event.button = mock.MagicMock(return_value=QtCore.Qt.LeftButton) + event.globalPos = mock.MagicMock(return_value=QtCore.QPoint(1, 1)) + with mock.patch.object( + help_inspector._app, "widgetAt", side_effect=[help_inspector, abort_button] + ): + # Return for self call + help_inspector.eventFilter(obj, event) + mock_toggle.assert_called_once_with(False) + mock_toggle.reset_mock() + # Run Callback for abort_button + callback_data = [] + + def _my_callback(widget): + callback_data.append(widget) + + help_inspector.register_callback(_my_callback) + + help_inspector.eventFilter(obj, event) + mock_toggle.assert_not_called() + assert len(callback_data) == 1 + assert callback_data[0] == abort_button + callback_data.clear() From 140f126560b8efaf99c9e4fa2ce4ddb4ca46997c Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Fri, 17 Oct 2025 13:55:00 +0200 Subject: [PATCH 100/161] feat(ads): add pyi stub file to provide type hints for ads --- .../widgets/containers/ads/__init__.py | 1 + .../widgets/containers/ads/__init__.pyi | 1039 +++++++++++++++++ 2 files changed, 1040 insertions(+) create mode 100644 bec_widgets/widgets/containers/ads/__init__.py create mode 100644 bec_widgets/widgets/containers/ads/__init__.pyi diff --git a/bec_widgets/widgets/containers/ads/__init__.py b/bec_widgets/widgets/containers/ads/__init__.py new file mode 100644 index 000000000..aa837994c --- /dev/null +++ b/bec_widgets/widgets/containers/ads/__init__.py @@ -0,0 +1 @@ +from PySide6QtAds import * diff --git a/bec_widgets/widgets/containers/ads/__init__.pyi b/bec_widgets/widgets/containers/ads/__init__.pyi new file mode 100644 index 000000000..7bb78f08b --- /dev/null +++ b/bec_widgets/widgets/containers/ads/__init__.pyi @@ -0,0 +1,1039 @@ +from __future__ import annotations + +import collections +import enum +import typing + +from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import Signal + +# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called + +class SideBarLocation(enum.Enum): + SideBarTop = ... + SideBarLeft = ... + SideBarRight = ... + SideBarBottom = ... + SideBarNone = ... + +class eBitwiseOperator(enum.Enum): + BitwiseAnd = ... + BitwiseOr = ... + +class eIcon(enum.Enum): + TabCloseIcon = ... + AutoHideIcon = ... + DockAreaMenuIcon = ... + DockAreaUndockIcon = ... + DockAreaCloseIcon = ... + DockAreaMinimizeIcon = ... + IconCount = ... + +class eDragState(enum.Enum): + DraggingInactive = ... + DraggingMousePressed = ... + DraggingTab = ... + DraggingFloatingWidget = ... + +class TitleBarButton(enum.Enum): + TitleBarButtonTabsMenu = ... + TitleBarButtonUndock = ... + TitleBarButtonClose = ... + TitleBarButtonAutoHide = ... + TitleBarButtonMinimize = ... + +class eTabIndex(enum.Enum): + TabDefaultInsertIndex = ... + TabInvalidIndex = ... + +class DockWidgetArea(enum.Enum): + NoDockWidgetArea = ... + LeftDockWidgetArea = ... + RightDockWidgetArea = ... + TopDockWidgetArea = ... + BottomDockWidgetArea = ... + CenterDockWidgetArea = ... + LeftAutoHideArea = ... + RightAutoHideArea = ... + TopAutoHideArea = ... + BottomAutoHideArea = ... + InvalidDockWidgetArea = ... + OuterDockAreas = ... + AutoHideDockAreas = ... + AllDockAreas = ... + +class CAutoHideDockContainer(QtWidgets.QFrame): + def __init__( + self, + DockWidget: typing.Optional["CDockWidget"], + area: SideBarLocation, + parent: typing.Optional["CDockContainerWidget"], + ) -> None: ... + def moveToNewSideBarLocation(self, a0: SideBarLocation) -> None: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def resetToInitialDockWidgetSize(self) -> None: ... + def setSize(self, Size: int) -> None: ... + def toggleCollapseState(self) -> None: ... + def collapseView(self, Enable: bool) -> None: ... + def toggleView(self, Enable: bool) -> None: ... + def cleanupAndDelete(self) -> None: ... + def moveContentsToParent(self) -> None: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def setSideBarLocation(self, SideBarLocation: SideBarLocation) -> None: ... + def sideBarLocation(self) -> SideBarLocation: ... + def addDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def tabIndex(self) -> int: ... + def dockWidget(self) -> typing.Optional["CDockWidget"]: ... + def autoHideTab(self) -> typing.Optional["CAutoHideTab"]: ... + def autoHideSideBar(self) -> typing.Optional["CAutoHideSideBar"]: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def updateSize(self) -> None: ... + def event(self, event: typing.Optional[QtCore.QEvent]) -> bool: ... + def leaveEvent(self, event: typing.Optional[QtCore.QEvent]) -> None: ... + def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + +class CAutoHideSideBar(QtWidgets.QScrollArea): + def __init__( + self, parent: typing.Optional["CDockContainerWidget"], area: "SideBarLocation" + ) -> None: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def setSpacing(self, Spacing: int) -> None: ... + def spacing(self) -> int: ... + def sizeHint(self) -> QtCore.QSize: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def sideBarLocation(self) -> "SideBarLocation": ... + def hasVisibleTabs(self) -> bool: ... + def visibleTabCount(self) -> int: ... + def count(self) -> int: ... + def indexOfTab(self, Tab: "CAutoHideTab") -> int: ... + def tabInsertIndexAt(self, Pos: QtCore.QPoint) -> int: ... + def tabAt(self, Pos: QtCore.QPoint) -> int: ... + def tab(self, index: int) -> typing.Optional["CAutoHideTab"]: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def addAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"], Index: int + ) -> None: ... + def removeAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"] + ) -> None: ... + def insertDockWidget( + self, Index: int, DockWidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def removeTab(self, SideTab: typing.Optional["CAutoHideTab"]) -> None: ... + def insertTab(self, Index: int, SideTab: typing.Optional["CAutoHideTab"]) -> None: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + +class CPushButton(QtWidgets.QPushButton): + class Orientation(enum.Enum): + Horizontal = ... + VerticalTopToBottom = ... + VerticalBottomToTop = ... + + def __init__(self) -> None: ... + def setButtonOrientation(self, orientation: "CPushButton.Orientation") -> None: ... + def buttonOrientation(self) -> "CPushButton.Orientation": ... + def sizeHint(self) -> QtCore.QSize: ... + +class CAutoHideTab(CPushButton): + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + def requestCloseDockWidget(self) -> None: ... + def unpinDockWidget(self) -> None: ... + def setDockWidgetFloating(self) -> None: ... + def tabIndex(self) -> int: ... + def sideBar(self) -> typing.Optional["CAutoHideSideBar"]: ... + def iconOnly(self) -> bool: ... + def setDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def dockWidget(self) -> typing.Optional["CDockWidget"]: ... + def isActiveTab(self) -> bool: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def setOrientation(self, Orientation: QtCore.Qt.Orientation) -> None: ... + def sideBarLocation(self) -> "SideBarLocation": ... + def updateStyle(self) -> None: ... + def dragLeaveEvent(self, ev: typing.Optional[QtGui.QDragLeaveEvent]) -> None: ... + def dragEnterEvent(self, ev: typing.Optional[QtGui.QDragEnterEvent]) -> None: ... + def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def contextMenuEvent(self, ev: typing.Optional[QtGui.QContextMenuEvent]) -> None: ... + def event(self, event: typing.Optional[QtCore.QEvent]) -> bool: ... + def removeFromSideBar(self) -> None: ... + def setSideBar(self, SideTabBar: typing.Optional["CAutoHideSideBar"]) -> None: ... + +class CDockWidget(QtWidgets.QFrame): + class eToggleViewActionMode(enum.Enum): + ActionModeToggle = ... + ActionModeShow = ... + + class eMinimumSizeHintMode(enum.Enum): + MinimumSizeHintFromDockWidget = ... + MinimumSizeHintFromContent = ... + MinimumSizeHintFromDockWidgetMinimumSize = ... + MinimumSizeHintFromContentMinimumSize = ... + + class eInsertMode(enum.Enum): + AutoScrollArea = ... + ForceScrollArea = ... + ForceNoScrollArea = ... + + class eToolBarStyleSource(enum.Enum): + ToolBarStyleFromDockManager = ... + ToolBarStyleFromDockWidget = ... + + class eState(enum.Enum): + StateHidden = ... + StateDocked = ... + StateFloating = ... + + class DockWidgetFeature(enum.Enum): + DockWidgetClosable = ... + DockWidgetMovable = ... + DockWidgetFloatable = ... + DockWidgetDeleteOnClose = ... + CustomCloseHandling = ... + DockWidgetFocusable = ... + DockWidgetForceCloseWithArea = ... + NoTab = ... + DeleteContentOnClose = ... + DockWidgetPinnable = ... + DefaultDockWidgetFeatures = ... + AllDockWidgetFeatures = ... + DockWidgetAlwaysCloseAndDelete = ... + GloballyLockableFeatures = ... + NoDockWidgetFeatures = ... + + @typing.overload + def __init__( + self, title: typing.Optional[str], parent: typing.Optional[QtWidgets.QWidget] = ... + ) -> None: ... + @typing.overload + def __init__( + self, + manager: typing.Optional["CDockManager"], + title: typing.Optional[str], + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + + featuresChanged: typing.ClassVar[Signal] + visibilityChanged: typing.ClassVar[Signal] + closeRequested: typing.ClassVar[Signal] + topLevelChanged: typing.ClassVar[Signal] + titleChanged: typing.ClassVar[Signal] + closed: typing.ClassVar[Signal] + viewToggled: typing.ClassVar[Signal] + def toggleAutoHide(self, Location: "SideBarLocation" = ...) -> None: ... + def setAutoHide( + self, Enable: bool, Location: "SideBarLocation" = ..., TabIndex: int = ... + ) -> None: ... + def showNormal(self) -> None: ... + def showFullScreen(self) -> None: ... + def requestCloseDockWidget(self) -> None: ... + def closeDockWidget(self) -> None: ... + def deleteDockWidget(self) -> None: ... + def setFloating(self) -> None: ... + def raise_(self) -> None: ... + def setAsCurrentTab(self) -> None: ... + def toggleView(self, Open: bool = ...) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def isCurrentTab(self) -> bool: ... + def isTabbed(self) -> bool: ... + def isFullScreen(self) -> bool: ... + def setTabToolTip(self, text: typing.Optional[str]) -> None: ... + def titleBarActions(self) -> list[QtGui.QAction]: ... + def setTitleBarActions(self, actions: collections.abc.Iterable[QtGui.QAction]) -> None: ... + def toolBarIconSize(self, State: "CDockWidget.eState") -> QtCore.QSize: ... + def setToolBarIconSize(self, IconSize: QtCore.QSize, State: "CDockWidget.eState") -> None: ... + def toolBarStyle(self, State: "CDockWidget.eState") -> QtCore.Qt.ToolButtonStyle: ... + def setToolBarStyle( + self, Style: QtCore.Qt.ToolButtonStyle, State: "CDockWidget.eState" + ) -> None: ... + def toolBarStyleSource(self) -> "CDockWidget.eToolBarStyleSource": ... + def setToolBarStyleSource(self, Source: "CDockWidget.eToolBarStyleSource") -> None: ... + def setToolBar(self, ToolBar: typing.Optional[QtWidgets.QToolBar]) -> None: ... + def createDefaultToolBar(self) -> typing.Optional[QtWidgets.QToolBar]: ... + def toolBar(self) -> typing.Optional[QtWidgets.QToolBar]: ... + def icon(self) -> QtGui.QIcon: ... + def setIcon(self, Icon: QtGui.QIcon) -> None: ... + def isCentralWidget(self) -> bool: ... + def minimumSizeHintMode(self) -> "CDockWidget.eMinimumSizeHintMode": ... + def setMinimumSizeHintMode(self, Mode: "CDockWidget.eMinimumSizeHintMode") -> None: ... + def setToggleViewActionMode(self, Mode: "CDockWidget.eToggleViewActionMode") -> None: ... + def setToggleViewAction(self, action: typing.Optional[QtGui.QAction]) -> None: ... + def toggleViewAction(self) -> typing.Optional[QtGui.QAction]: ... + def isClosed(self) -> bool: ... + def isInFloatingContainer(self) -> bool: ... + def isFloating(self) -> bool: ... + def autoHideLocation(self) -> "SideBarLocation": ... + def autoHideDockContainer(self) -> typing.Optional["CAutoHideDockContainer"]: ... + def isAutoHide(self) -> bool: ... + def setSideTabWidget(self, SideTab: typing.Optional["CAutoHideTab"]) -> None: ... + def sideTabWidget(self) -> typing.Optional["CAutoHideTab"]: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def floatingDockContainer(self) -> typing.Optional["CFloatingDockContainer"]: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def dockManager(self) -> typing.Optional["CDockManager"]: ... + def features(self) -> "CDockWidget.DockWidgetFeature": ... + def setFeature(self, flag: "CDockWidget.DockWidgetFeature", on: bool) -> None: ... + def setFeatures(self, features: "CDockWidget.DockWidgetFeature") -> None: ... + def tabWidget(self) -> typing.Optional["CDockWidgetTab"]: ... + def widget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def takeWidget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def setWidget( + self, + widget: typing.Optional[QtWidgets.QWidget], + InsertMode: "CDockWidget.eInsertMode" = ..., + ) -> None: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def closeDockWidgetInternal(self, ForceClose: bool = ...) -> bool: ... + def toggleViewInternal(self, Open: bool) -> None: ... + def setClosedState(self, Closed: bool) -> None: ... + def emitTopLevelChanged(self, Floating: bool) -> None: ... + @staticmethod + def emitTopLevelEventForWidget( + TopLevelDockWidget: typing.Optional["CDockWidget"], Floating: bool + ) -> None: ... + def flagAsUnassigned(self) -> None: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def setToggleViewActionChecked(self, Checked: bool) -> None: ... + def setDockArea(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ... + def setDockManager(self, DockManager: typing.Optional["CDockManager"]) -> None: ... + +class CDockAreaTabBar(QtWidgets.QScrollArea): + def __init__(self, parent: typing.Optional["CDockAreaWidget"]) -> None: ... + + elidedChanged: typing.ClassVar[Signal] + tabInserted: typing.ClassVar[Signal] + removingTab: typing.ClassVar[Signal] + tabMoved: typing.ClassVar[Signal] + tabOpened: typing.ClassVar[Signal] + tabClosed: typing.ClassVar[Signal] + tabCloseRequested: typing.ClassVar[Signal] + tabBarClicked: typing.ClassVar[Signal] + currentChanged: typing.ClassVar[Signal] + currentChanging: typing.ClassVar[Signal] + def closeTab(self, Index: int) -> None: ... + def setCurrentIndex(self, Index: int) -> None: ... + def areTabsOverflowing(self) -> bool: ... + def sizeHint(self) -> QtCore.QSize: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def isTabOpen(self, Index: int) -> bool: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + def tabInsertIndexAt(self, Pos: QtCore.QPoint) -> int: ... + def tabAt(self, Pos: QtCore.QPoint) -> int: ... + def tab(self, Index: int) -> typing.Optional["CDockWidgetTab"]: ... + def currentTab(self) -> typing.Optional["CDockWidgetTab"]: ... + def currentIndex(self) -> int: ... + def count(self) -> int: ... + def removeTab(self, Tab: typing.Optional["CDockWidgetTab"]) -> None: ... + def insertTab(self, Index: int, Tab: typing.Optional["CDockWidgetTab"]) -> None: ... + def wheelEvent(self, Event: typing.Optional[QtGui.QWheelEvent]) -> None: ... + +class CSpacerWidget(QtWidgets.QWidget): + def __init__(self, Parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def sizeHint(self) -> QtCore.QSize: ... + +class CTitleBarButton(QtWidgets.QToolButton): + def __init__( + self, + ShowInTitleBar: bool, + HideWhenDisabled: bool, + ButtonId: "TitleBarButton", + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + def event(self, ev: typing.Optional[QtCore.QEvent]) -> bool: ... + def isInAutoHideArea(self) -> bool: ... + def titleBar(self) -> typing.Optional["CDockAreaTitleBar"]: ... + def buttonId(self) -> "TitleBarButton": ... + def setShowInTitleBar(self, a0: bool) -> None: ... + def setVisible(self, a0: bool) -> None: ... + +class CDockAreaTitleBar(QtWidgets.QFrame): + def __init__(self, parent: typing.Optional[CDockAreaWidget]) -> None: ... + + tabBarClicked: typing.ClassVar[Signal] + def buildContextMenu( + self, menu: typing.Optional[QtWidgets.QMenu] = ... + ) -> typing.Optional[QtWidgets.QMenu]: ... + def isAutoHide(self) -> bool: ... + def showAutoHideControls(self, Show: bool) -> None: ... + def setAreaFloating(self) -> None: ... + def titleBarButtonToolTip(self, Button: "TitleBarButton") -> str: ... + def indexOf(self, widget: typing.Optional[QtWidgets.QWidget]) -> int: ... + def insertWidget(self, index: int, widget: typing.Optional[QtWidgets.QWidget]) -> None: ... + def setVisible(self, Visible: bool) -> None: ... + def updateDockWidgetActionsButtons(self) -> None: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def autoHideTitleLabel(self) -> typing.Optional["CElidingLabel"]: ... + def button(self, which: "TitleBarButton") -> typing.Optional["CTitleBarButton"]: ... + def tabBar(self) -> typing.Optional["CDockAreaTabBar"]: ... + def markTabsMenuOutdated(self) -> None: ... + def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ... + def contextMenuEvent(self, event: typing.Optional[QtGui.QContextMenuEvent]) -> None: ... + def mouseDoubleClickEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + +class CDockAreaWidget(QtWidgets.QFrame): + class eDockAreaFlag(enum.Enum): + HideSingleWidgetTitleBar = ... + DefaultFlags = ... + + def __init__( + self, + DockManager: typing.Optional[CDockManager], + parent: typing.Optional[CDockContainerWidget], + ) -> None: ... + + viewToggled: typing.ClassVar[Signal] + currentChanged: typing.ClassVar[Signal] + currentChanging: typing.ClassVar[Signal] + tabBarClicked: typing.ClassVar[Signal] + def setFloating(self) -> None: ... + def closeOtherAreas(self) -> None: ... + def toggleAutoHide(self, Location: "SideBarLocation" = ...) -> None: ... + def setAutoHide( + self, Enable: bool, Location: "SideBarLocation" = ..., TabIndex: int = ... + ) -> None: ... + def closeArea(self) -> None: ... + def setCurrentIndex(self, index: int) -> None: ... + def isTopLevelArea(self) -> bool: ... + def containsCentralWidget(self) -> bool: ... + def isCentralWidgetArea(self) -> bool: ... + def setDockAreaFlag(self, Flag: "CDockAreaWidget.eDockAreaFlag", On: bool) -> None: ... + def setDockAreaFlags(self, Flags: "CDockAreaWidget.eDockAreaFlag") -> None: ... + def dockAreaFlags(self) -> "CDockAreaWidget.eDockAreaFlag": ... + def titleBar(self) -> typing.Optional["CDockAreaTitleBar"]: ... + def allowedAreas(self) -> "DockWidgetArea": ... + def setAllowedAreas(self, areas: "DockWidgetArea") -> None: ... + def setVisible(self, Visible: bool) -> None: ... + def titleBarButton( + self, which: "TitleBarButton" + ) -> typing.Optional[QtWidgets.QAbstractButton]: ... + def features(self, Mode: "eBitwiseOperator" = ...) -> "CDockWidget.DockWidgetFeature": ... + @staticmethod + def restoreState( + Stream: "CDockingStateReader", + Testing: bool, + ParentContainer: typing.Optional["CDockContainerWidget"], + ) -> typing.Tuple[bool, typing.Optional["CDockAreaWidget"]]: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def setCurrentDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def currentDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def indexOfFirstOpenDockWidget(self) -> int: ... + def currentIndex(self) -> int: ... + def dockWidget(self, Index: int) -> typing.Optional["CDockWidget"]: ... + def openedDockWidgets(self) -> list["CDockWidget"]: ... + def openDockWidgetsCount(self) -> int: ... + def dockWidgets(self) -> list["CDockWidget"]: ... + def dockWidgetsCount(self) -> int: ... + def contentAreaGeometry(self) -> QtCore.QRect: ... + def titleBarGeometry(self) -> QtCore.QRect: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def setAutoHideDockContainer(self, a0: typing.Optional["CAutoHideDockContainer"]) -> None: ... + def isAutoHide(self) -> bool: ... + def parentSplitter(self) -> typing.Optional["CDockSplitter"]: ... + def autoHideDockContainer(self) -> typing.Optional["CAutoHideDockContainer"]: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def dockManager(self) -> typing.Optional["CDockManager"]: ... + def toggleView(self, Open: bool) -> None: ... + def updateTitleBarButtonVisibility(self, IsTopLevel: bool) -> None: ... + def markTitleBarMenuOutdated(self) -> None: ... + def internalSetCurrentDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def updateTitleBarVisibility(self) -> None: ... + def hideAreaWithNoVisibleContent(self) -> None: ... + def index(self, DockWidget: typing.Optional["CDockWidget"]) -> int: ... + def nextOpenDockWidget( + self, DockWidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CDockWidget"]: ... + def toggleDockWidgetView( + self, DockWidget: typing.Optional["CDockWidget"], Open: bool + ) -> None: ... + def removeDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def addDockWidget(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def insertDockWidget( + self, index: int, DockWidget: typing.Optional["CDockWidget"], Activate: bool = ... + ) -> None: ... + +class CDockContainerWidget(QtWidgets.QFrame): + def __init__( + self, + DockManager: typing.Optional["CDockManager"], + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + + dockAreaViewToggled: typing.ClassVar[Signal] + dockAreasRemoved: typing.ClassVar[Signal] + autoHideWidgetCreated: typing.ClassVar[Signal] + dockAreasAdded: typing.ClassVar[Signal] + def dockManager(self) -> typing.Optional["CDockManager"]: ... + def contentRectGlobal(self) -> QtCore.QRect: ... + def contentRect(self) -> QtCore.QRect: ... + def autoHideWidgets(self) -> list["CAutoHideDockContainer"]: ... + def autoHideSideBar(self, area: "SideBarLocation") -> typing.Optional["CAutoHideSideBar"]: ... + def closeOtherAreas(self, KeepOpenArea: typing.Optional["CDockAreaWidget"]) -> None: ... + def floatingWidget(self) -> typing.Optional["CFloatingDockContainer"]: ... + def features(self) -> "CDockWidget.DockWidgetFeature": ... + def dumpLayout(self) -> None: ... + def isFloating(self) -> bool: ... + def visibleDockAreaCount(self) -> int: ... + def dockAreaCount(self) -> int: ... + def hasTopLevelDockWidget(self) -> bool: ... + def openedDockWidgets(self) -> list["CDockWidget"]: ... + def openedDockAreas(self) -> list["CDockAreaWidget"]: ... + def dockArea(self, Index: int) -> typing.Optional["CDockAreaWidget"]: ... + def dockAreaAt(self, GlobalPos: QtCore.QPoint) -> typing.Optional["CDockAreaWidget"]: ... + def isInFrontOf(self, Other: typing.Optional["CDockContainerWidget"]) -> bool: ... + def zOrderIndex(self) -> int: ... + def removeDockWidget(self, Dockwidget: typing.Optional["CDockWidget"]) -> None: ... + def addDockWidget( + self, + area: "DockWidgetArea", + Dockwidget: typing.Optional["CDockWidget"], + DockAreaWidget: typing.Optional["CDockAreaWidget"] = ..., + Index: int = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + def handleAutoHideWidgetEvent( + self, e: typing.Optional[QtCore.QEvent], w: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + def removeAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"] + ) -> None: ... + def registerAutoHideWidget( + self, AutoHideWidget: typing.Optional["CAutoHideDockContainer"] + ) -> None: ... + def updateSplitterHandles(self, splitter: typing.Optional[QtWidgets.QSplitter]) -> None: ... + def dockWidgets(self) -> list["CDockWidget"]: ... + def topLevelDockArea(self) -> typing.Optional["CDockAreaWidget"]: ... + def topLevelDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def lastAddedDockAreaWidget( + self, area: "DockWidgetArea" + ) -> typing.Optional["CDockAreaWidget"]: ... + def restoreState(self, Stream: "CDockingStateReader", Testing: bool) -> bool: ... + def saveState(self, Stream: QtCore.QXmlStreamWriter) -> None: ... + def removeAllDockAreas(self) -> list[CDockAreaWidget]: ... + def removeDockArea(self, area: typing.Optional["CDockAreaWidget"]) -> None: ... + def addDockArea( + self, DockAreaWidget: typing.Optional["CDockAreaWidget"], area: "DockWidgetArea" = ... + ) -> None: ... + def dropWidget( + self, + Widget: typing.Optional[QtWidgets.QWidget], + DropArea: "DockWidgetArea", + TargetAreaWidget: typing.Optional["CDockAreaWidget"], + TabIndex: int = ..., + ) -> None: ... + def dropFloatingWidget( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"], TargetPos: QtCore.QPoint + ) -> None: ... + def createRootSplitter(self) -> None: ... + def createAndSetupAutoHideContainer( + self, + area: "SideBarLocation", + DockWidget: typing.Optional["CDockWidget"], + TabIndex: int = ..., + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def rootSplitter(self) -> typing.Optional["CDockSplitter"]: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + +class CDockingStateReader(QtCore.QXmlStreamReader): + def __init__(self) -> None: ... + def fileVersion(self) -> int: ... + def setFileVersion(self, FileVersion: int) -> None: ... + +class CDockFocusController(QtCore.QObject): + def __init__(self, DockManager: typing.Optional["CDockManager"]) -> None: ... + def setDockWidgetFocused(self, focusedNow: typing.Optional["CDockWidget"]) -> None: ... + def setDockWidgetTabPressed(self, Value: bool) -> None: ... + def clearDockWidgetFocus(self, dockWidget: typing.Optional["CDockWidget"]) -> None: ... + def setDockWidgetTabFocused(self, Tab: typing.Optional["CDockWidgetTab"]) -> None: ... + def focusedDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def notifyFloatingWidgetDrop( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + def notifyWidgetOrAreaRelocation( + self, RelocatedWidget: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + +class CDockManager(CDockContainerWidget): + class eConfigParam(enum.Enum): + AutoHideOpenOnDragHoverDelay_ms = ... + ConfigParamCount = ... + + class eAutoHideFlag(enum.Enum): + AutoHideFeatureEnabled = ... + DockAreaHasAutoHideButton = ... + AutoHideButtonTogglesArea = ... + AutoHideButtonCheckable = ... + AutoHideSideBarsIconOnly = ... + AutoHideShowOnMouseOver = ... + AutoHideCloseButtonCollapsesDock = ... + AutoHideHasCloseButton = ... + AutoHideHasMinimizeButton = ... + AutoHideOpenOnDragHover = ... + AutoHideCloseOnOutsideMouseClick = ... + DefaultAutoHideConfig = ... + + class eConfigFlag(enum.Enum): + ActiveTabHasCloseButton = ... + DockAreaHasCloseButton = ... + DockAreaCloseButtonClosesTab = ... + OpaqueSplitterResize = ... + XmlAutoFormattingEnabled = ... + XmlCompressionEnabled = ... + TabCloseButtonIsToolButton = ... + AllTabsHaveCloseButton = ... + RetainTabSizeWhenCloseButtonHidden = ... + DragPreviewIsDynamic = ... + DragPreviewShowsContentPixmap = ... + DragPreviewHasWindowFrame = ... + AlwaysShowTabs = ... + DockAreaHasUndockButton = ... + DockAreaHasTabsMenuButton = ... + DockAreaHideDisabledButtons = ... + DockAreaDynamicTabsMenuButtonVisibility = ... + FloatingContainerHasWidgetTitle = ... + FloatingContainerHasWidgetIcon = ... + HideSingleCentralWidgetTitleBar = ... + FocusHighlighting = ... + EqualSplitOnInsertion = ... + FloatingContainerForceNativeTitleBar = ... + FloatingContainerForceQWidgetTitleBar = ... + MiddleMouseButtonClosesTab = ... + DisableTabTextEliding = ... + ShowTabTextOnlyForActiveTab = ... + DoubleClickUndocksWidget = ... + DefaultDockAreaButtons = ... + DefaultBaseConfig = ... + DefaultOpaqueConfig = ... + DefaultNonOpaqueConfig = ... + NonOpaqueWithWindowFrame = ... + + class eViewMenuInsertionOrder(enum.Enum): + MenuSortedByInsertion = ... + MenuAlphabeticallySorted = ... + + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + + focusedDockWidgetChanged: typing.ClassVar[Signal] + dockWidgetRemoved: typing.ClassVar[Signal] + dockWidgetAboutToBeRemoved: typing.ClassVar[Signal] + dockWidgetAdded: typing.ClassVar[Signal] + dockAreaCreated: typing.ClassVar[Signal] + floatingWidgetCreated: typing.ClassVar[Signal] + perspectiveOpened: typing.ClassVar[Signal] + openingPerspective: typing.ClassVar[Signal] + stateRestored: typing.ClassVar[Signal] + restoringState: typing.ClassVar[Signal] + perspectivesRemoved: typing.ClassVar[Signal] + perspectiveListLoaded: typing.ClassVar[Signal] + perspectiveListChanged: typing.ClassVar[Signal] + def hideManagerAndFloatingWidgets(self) -> None: ... + def setDockWidgetFocused(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def openPerspective(self, PerspectiveName: typing.Optional[str]) -> None: ... + def endLeavingMinimizedState(self) -> None: ... + def lockDockWidgetFeaturesGlobally( + self, Features: CDockWidget.DockWidgetFeature = ... + ) -> None: ... + def globallyLockedDockWidgetFeatures(self) -> CDockWidget.DockWidgetFeature: ... + def dockWidgetToolBarIconSize(self, State: CDockWidget.eState) -> QtCore.QSize: ... + def setDockWidgetToolBarIconSize( + self, IconSize: QtCore.QSize, State: CDockWidget.eState + ) -> None: ... + def dockWidgetToolBarStyle(self, State: CDockWidget.eState) -> QtCore.Qt.ToolButtonStyle: ... + def setDockWidgetToolBarStyle( + self, Style: QtCore.Qt.ToolButtonStyle, State: CDockWidget.eState + ) -> None: ... + @staticmethod + def floatingContainersTitle() -> str: ... + @staticmethod + def setFloatingContainersTitle(Title: typing.Optional[str]) -> None: ... + def setSplitterSizes( + self, + ContainedArea: typing.Optional["CDockAreaWidget"], + sizes: collections.abc.Iterable[int], + ) -> None: ... + def splitterSizes(self, ContainedArea: typing.Optional["CDockAreaWidget"]) -> list[int]: ... + def focusedDockWidget(self) -> typing.Optional["CDockWidget"]: ... + @staticmethod + def startDragDistance() -> int: ... + def isLeavingMinimizedState(self) -> bool: ... + def isRestoringState(self) -> bool: ... + def setViewMenuInsertionOrder(self, Order: "CDockManager.eViewMenuInsertionOrder") -> None: ... + def viewMenu(self) -> typing.Optional[QtWidgets.QMenu]: ... + def addToggleViewActionToMenu( + self, + ToggleViewAction: typing.Optional[QtGui.QAction], + Group: typing.Optional[str] = ..., + GroupIcon: QtGui.QIcon = ..., + ) -> typing.Optional[QtGui.QAction]: ... + def setCentralWidget( + self, widget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CDockAreaWidget"]: ... + def centralWidget(self) -> typing.Optional["CDockWidget"]: ... + def loadPerspectives(self, Settings: QtCore.QSettings) -> None: ... + def savePerspectives(self, Settings: QtCore.QSettings) -> None: ... + def perspectiveNames(self) -> list[str]: ... + def removePerspectives(self, Names: collections.abc.Iterable[typing.Optional[str]]) -> None: ... + def removePerspective(self, Name: typing.Optional[str]) -> None: ... + def addPerspective(self, UniquePrespectiveName: typing.Optional[str]) -> None: ... + def restoreState( + self, + state: typing.Union[QtCore.QByteArray, bytes, bytearray, memoryview], + version: int = ..., + ) -> bool: ... + def saveState(self, version: int = ...) -> QtCore.QByteArray: ... + def zOrderIndex(self) -> int: ... + def floatingWidgets(self) -> list["CFloatingDockContainer"]: ... + def dockContainers(self) -> list["CDockContainerWidget"]: ... + def dockWidgetsMap(self) -> dict[str, CDockWidget]: ... + def removeDockWidget(self, Dockwidget: typing.Optional["CDockWidget"]) -> None: ... + def findDockWidget( + self, ObjectName: typing.Optional[str] + ) -> typing.Optional["CDockWidget"]: ... + def addDockWidgetFloating( + self, DockWidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CFloatingDockContainer"]: ... + def addDockWidgetTabToArea( + self, + Dockwidget: typing.Optional["CDockWidget"], + DockAreaWidget: typing.Optional["CDockAreaWidget"], + Index: int = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + def addDockWidgetTab( + self, area: "DockWidgetArea", Dockwidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CDockAreaWidget"]: ... + def addAutoHideDockWidgetToContainer( + self, + Location: "SideBarLocation", + Dockwidget: typing.Optional["CDockWidget"], + DockContainerWidget: typing.Optional["CDockContainerWidget"], + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def addAutoHideDockWidget( + self, Location: "SideBarLocation", Dockwidget: typing.Optional["CDockWidget"] + ) -> typing.Optional["CAutoHideDockContainer"]: ... + def addDockWidgetToContainer( + self, + area: "DockWidgetArea", + Dockwidget: typing.Optional["CDockWidget"], + DockContainerWidget: typing.Optional["CDockContainerWidget"] = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + def addDockWidget( + self, + area: "DockWidgetArea", + Dockwidget: typing.Optional["CDockWidget"], + DockAreaWidget: typing.Optional["CDockAreaWidget"] = ..., + Index: int = ..., + ) -> typing.Optional["CDockAreaWidget"]: ... + @staticmethod + def iconProvider() -> "CIconProvider": ... + @staticmethod + def configParam(Param: "CDockManager.eConfigParam", Default: typing.Any) -> typing.Any: ... + @staticmethod + def setConfigParam(Param: "CDockManager.eConfigParam", Value: typing.Any) -> None: ... + @staticmethod + def testAutoHideConfigFlag(Flag: "CDockManager.eAutoHideFlag") -> bool: ... + @staticmethod + def setAutoHideConfigFlag(Flag: "CDockManager.eAutoHideFlag", On: bool = ...) -> None: ... + @staticmethod + def setAutoHideConfigFlags(Flags: "CDockManager.eAutoHideFlag") -> None: ... + @staticmethod + def autoHideConfigFlags() -> "CDockManager.eAutoHideFlag": ... + @staticmethod + def testConfigFlag(Flag: "CDockManager.eConfigFlag") -> bool: ... + @staticmethod + def setConfigFlag(Flag: "CDockManager.eConfigFlag", On: bool = ...) -> None: ... + @staticmethod + def setConfigFlags(Flags: "CDockManager.eConfigFlag") -> None: ... + @staticmethod + def configFlags() -> "CDockManager.eConfigFlag": ... + def setComponentsFactory(self, Factory: typing.Optional["CDockComponentsFactory"]) -> None: ... + def componentsFactory(self) -> typing.Optional["CDockComponentsFactory"]: ... + def createDockWidget( + self, title: typing.Optional[str], parent: typing.Optional[QtWidgets.QWidget] = ... + ) -> typing.Optional["CDockWidget"]: ... + def showEvent(self, event: typing.Optional[QtGui.QShowEvent]) -> None: ... + def notifyFloatingWidgetDrop( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + def notifyWidgetOrAreaRelocation( + self, RelocatedWidget: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + def dockAreaOverlay(self) -> typing.Optional["CDockOverlay"]: ... + def containerOverlay(self) -> typing.Optional["CDockOverlay"]: ... + def removeDockContainer( + self, DockContainer: typing.Optional["CDockContainerWidget"] + ) -> None: ... + def registerDockContainer( + self, DockContainer: typing.Optional["CDockContainerWidget"] + ) -> None: ... + def removeFloatingWidget( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + def registerFloatingWidget( + self, FloatingWidget: typing.Optional["CFloatingDockContainer"] + ) -> None: ... + +class CDockOverlay(QtWidgets.QFrame): + class eMode(enum.Enum): + ModeDockAreaOverlay = ... + ModeContainerOverlay = ... + + def __init__( + self, parent: typing.Optional[QtWidgets.QWidget], Mode: "CDockOverlay.eMode" = ... + ) -> None: ... + def hideEvent(self, e: typing.Optional[QtGui.QHideEvent]) -> None: ... + def showEvent(self, e: typing.Optional[QtGui.QShowEvent]) -> None: ... + def paintEvent(self, e: typing.Optional[QtGui.QPaintEvent]) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def dropOverlayRect(self) -> QtCore.QRect: ... + def dropPreviewEnabled(self) -> bool: ... + def enableDropPreview(self, Enable: bool) -> None: ... + def hideOverlay(self) -> None: ... + def showOverlay(self, target: typing.Optional[QtWidgets.QWidget]) -> "DockWidgetArea": ... + def visibleDropAreaUnderCursor(self) -> "DockWidgetArea": ... + def tabIndexUnderCursor(self) -> int: ... + def dropAreaUnderCursor(self) -> "DockWidgetArea": ... + def allowedAreas(self) -> "DockWidgetArea": ... + def setAllowedArea(self, area: "DockWidgetArea", Enable: bool) -> None: ... + def setAllowedAreas(self, areas: "DockWidgetArea") -> None: ... + +class CDockOverlayCross(QtWidgets.QWidget): + class eIconColor(enum.Enum): + FrameColor = ... + WindowBackgroundColor = ... + OverlayColor = ... + ArrowColor = ... + ShadowColor = ... + + def __init__(self, overlay: typing.Optional["CDockOverlay"]) -> None: ... + def setIconColors(self, Colors: typing.Optional[str]) -> None: ... + def updatePosition(self) -> None: ... + def reset(self) -> None: ... + def updateOverlayIcons(self) -> None: ... + def setupOverlayCross(self, Mode: "CDockOverlay.eMode") -> None: ... + def cursorLocation(self) -> "DockWidgetArea": ... + def setIconColor( + self, + ColorIndex: "CDockOverlayCross.eIconColor", + Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int], + ) -> None: ... + def setAreaWidgets(self, widgets: dict["DockWidgetArea", QtWidgets.QWidget]) -> None: ... + def showEvent(self, e: typing.Optional[QtGui.QShowEvent]) -> None: ... + def setIconShadowColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconArrowColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconOverlayColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconBackgroundColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + def setIconFrameColor( + self, Color: typing.Union[QtGui.QColor, QtCore.Qt.GlobalColor, int] + ) -> None: ... + @typing.overload + def iconColor(self) -> QtGui.QColor: ... + @typing.overload + def iconColor(self, ColorIndex: "CDockOverlayCross.eIconColor") -> QtGui.QColor: ... + def iconColors(self) -> str: ... + +class CDockSplitter(QtWidgets.QSplitter): + @typing.overload + def __init__(self, parent: typing.Optional[QtWidgets.QWidget] = ...) -> None: ... + @typing.overload + def __init__( + self, orientation: QtCore.Qt.Orientation, parent: typing.Optional[QtWidgets.QWidget] = ... + ) -> None: ... + def isResizingWithContainer(self) -> bool: ... + def lastWidget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def firstWidget(self) -> typing.Optional[QtWidgets.QWidget]: ... + def hasVisibleContent(self) -> bool: ... + +class CDockWidgetTab(QtWidgets.QFrame): + def __init__( + self, + DockWidget: typing.Optional["CDockWidget"], + parent: typing.Optional[QtWidgets.QWidget] = ..., + ) -> None: ... + + elidedChanged: typing.ClassVar[Signal] + moved: typing.ClassVar[Signal] + closeOtherTabsRequested: typing.ClassVar[Signal] + closeRequested: typing.ClassVar[Signal] + clicked: typing.ClassVar[Signal] + activeTabChanged: typing.ClassVar[Signal] + def setVisible(self, visible: bool) -> None: ... + def buildContextMenu( + self, menu: typing.Optional[QtWidgets.QMenu] = ... + ) -> typing.Optional[QtWidgets.QMenu]: ... + def dragState(self) -> "eDragState": ... + def setIconSize(self, Size: QtCore.QSize) -> None: ... + def iconSize(self) -> QtCore.QSize: ... + def updateStyle(self) -> None: ... + def setElideMode(self, mode: QtCore.Qt.TextElideMode) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def isClosable(self) -> bool: ... + def isTitleElided(self) -> bool: ... + def setText(self, title: typing.Optional[str]) -> None: ... + def text(self) -> str: ... + def icon(self) -> QtGui.QIcon: ... + def setIcon(self, Icon: QtGui.QIcon) -> None: ... + def dockWidget(self) -> typing.Optional["CDockWidget"]: ... + def dockAreaWidget(self) -> typing.Optional["CDockAreaWidget"]: ... + def setDockAreaWidget(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ... + def setActiveTab(self, active: bool) -> None: ... + def isActiveTab(self) -> bool: ... + def mouseDoubleClickEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def contextMenuEvent(self, ev: typing.Optional[QtGui.QContextMenuEvent]) -> None: ... + def mouseMoveEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseReleaseEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + +class CElidingLabel(QtWidgets.QLabel): + @typing.overload + def __init__( + self, parent: typing.Optional[QtWidgets.QWidget] = ..., f: QtCore.Qt.WindowType = ... + ) -> None: ... + @typing.overload + def __init__( + self, + text: typing.Optional[str], + parent: typing.Optional[QtWidgets.QWidget] = ..., + f: QtCore.Qt.WindowType = ..., + ) -> None: ... + + elidedChanged: typing.ClassVar[Signal] + doubleClicked: typing.ClassVar[Signal] + clicked: typing.ClassVar[Signal] + def text(self) -> str: ... + def setText(self, text: typing.Optional[str]) -> None: ... + def sizeHint(self) -> QtCore.QSize: ... + def minimumSizeHint(self) -> QtCore.QSize: ... + def isElided(self) -> bool: ... + def setElideMode(self, mode: QtCore.Qt.TextElideMode) -> None: ... + def elideMode(self) -> QtCore.Qt.TextElideMode: ... + def mouseDoubleClickEvent(self, ev: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def resizeEvent(self, event: typing.Optional[QtGui.QResizeEvent]) -> None: ... + def mouseReleaseEvent(self, event: typing.Optional[QtGui.QMouseEvent]) -> None: ... + +class IFloatingWidget: + @typing.overload + def __init__(self) -> None: ... + @typing.overload + def __init__(self, a0: "IFloatingWidget") -> None: ... + def finishDragging(self) -> None: ... + def moveFloating(self) -> None: ... + def startFloating( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + DragState: "eDragState", + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + +class CFloatingDockContainer(QtWidgets.QWidget, IFloatingWidget): + @typing.overload + def __init__(self, DockManager: typing.Optional["CDockManager"]) -> None: ... + @typing.overload + def __init__(self, DockArea: typing.Optional["CDockAreaWidget"]) -> None: ... + @typing.overload + def __init__(self, DockWidget: typing.Optional["CDockWidget"]) -> None: ... + def finishDropOperation(self) -> None: ... + def dockWidgets(self) -> list["CDockWidget"]: ... + def topLevelDockWidget(self) -> typing.Optional["CDockWidget"]: ... + def hasTopLevelDockWidget(self) -> bool: ... + def isClosable(self) -> bool: ... + def startDragging( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + def dockContainer(self) -> typing.Optional["CDockContainerWidget"]: ... + def moveEvent(self, event: typing.Optional[QtGui.QMoveEvent]) -> None: ... + def event(self, e: typing.Optional[QtCore.QEvent]) -> bool: ... + def showEvent(self, event: typing.Optional[QtGui.QShowEvent]) -> None: ... + def hideEvent(self, event: typing.Optional[QtGui.QHideEvent]) -> None: ... + def closeEvent(self, event: typing.Optional[QtGui.QCloseEvent]) -> None: ... + def changeEvent(self, event: typing.Optional[QtCore.QEvent]) -> None: ... + def updateWindowTitle(self) -> None: ... + def restoreState(self, Stream: "CDockingStateReader", Testing: bool) -> bool: ... + def moveFloating(self) -> None: ... + def initFloatingGeometry( + self, DragStartMousePos: QtCore.QPoint, Size: QtCore.QSize + ) -> None: ... + def deleteContent(self) -> None: ... + def finishDragging(self) -> None: ... + def startFloating( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + DragState: "eDragState", + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + +class CFloatingDragPreview(QtWidgets.QWidget, IFloatingWidget): + @typing.overload + def __init__( + self, + Content: typing.Optional[QtWidgets.QWidget], + parent: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + @typing.overload + def __init__(self, Content: typing.Optional["CDockWidget"]) -> None: ... + @typing.overload + def __init__(self, Content: typing.Optional["CDockAreaWidget"]) -> None: ... + + draggingCanceled: typing.ClassVar[Signal] + def cleanupAutoHideContainerWidget(self, ContainerDropArea: "DockWidgetArea") -> None: ... + def finishDragging(self) -> None: ... + def moveFloating(self) -> None: ... + def startFloating( + self, + DragStartMousePos: QtCore.QPoint, + Size: QtCore.QSize, + DragState: "eDragState", + MouseEventHandler: typing.Optional[QtWidgets.QWidget], + ) -> None: ... + def eventFilter( + self, watched: typing.Optional[QtCore.QObject], event: typing.Optional[QtCore.QEvent] + ) -> bool: ... + def paintEvent(self, e: typing.Optional[QtGui.QPaintEvent]) -> None: ... + +class CIconProvider: + @typing.overload + def __init__(self) -> None: ... + @typing.overload + def __init__(self, a0: CIconProvider) -> None: ... + def registerCustomIcon(self, IconId: "eIcon", icon: QtGui.QIcon) -> None: ... + def customIcon(self, IconId: "eIcon") -> QtGui.QIcon: ... + +class CResizeHandle(QtWidgets.QFrame): + def __init__( + self, HandlePosition: QtCore.Qt.Edge, parent: typing.Optional[QtWidgets.QWidget] + ) -> None: ... + def opaqueResize(self) -> bool: ... + def setOpaqueResize(self, opaque: bool = ...) -> None: ... + def setMaxResizeSize(self, MaxSize: int) -> None: ... + def setMinResizeSize(self, MinSize: int) -> None: ... + def isResizing(self) -> bool: ... + def sizeHint(self) -> QtCore.QSize: ... + def orientation(self) -> QtCore.Qt.Orientation: ... + def handlePostion(self) -> QtCore.Qt.Edge: ... + def setHandlePosition(self, HandlePosition: QtCore.Qt.Edge) -> None: ... + def mouseReleaseEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mousePressEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ... + def mouseMoveEvent(self, a0: typing.Optional[QtGui.QMouseEvent]) -> None: ... From 6b5aa74934b171fd45fbf4614321a7234c3a5148 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 27 Oct 2025 19:05:59 +0100 Subject: [PATCH 101/161] fix(toolbar): create_action_with_text text will not change to tooltip when disable/enable/checked --- bec_widgets/utils/toolbars/actions.py | 8 +++++++- bec_widgets/utils/toolbars/toolbar.py | 25 ++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 9cfc88ac3..8d87e0320 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -43,13 +43,19 @@ def create_action_with_text(toolbar_action, toolbar: QToolBar): """ btn = QToolButton(parent=toolbar) + if getattr(toolbar_action, "label_text", None): + toolbar_action.action.setText(toolbar_action.label_text) + if getattr(toolbar_action, "tooltip", None): + toolbar_action.action.setToolTip(toolbar_action.tooltip) + btn.setToolTip(toolbar_action.tooltip) + btn.setDefaultAction(toolbar_action.action) btn.setAutoRaise(True) if toolbar_action.text_position == "under": btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) else: btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - btn.setText(toolbar_action.label_text) + toolbar.addWidget(btn) diff --git a/bec_widgets/utils/toolbars/toolbar.py b/bec_widgets/utils/toolbars/toolbar.py index c1b7b7f28..4b10fba84 100644 --- a/bec_widgets/utils/toolbars/toolbar.py +++ b/bec_widgets/utils/toolbars/toolbar.py @@ -6,7 +6,7 @@ from typing import DefaultDict, Literal from bec_lib.logger import bec_logger -from qtpy.QtCore import QSize, Qt +from qtpy.QtCore import QSize, Qt, QTimer from qtpy.QtGui import QAction, QColor from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMenu, QToolBar, QVBoxLayout, QWidget @@ -492,10 +492,33 @@ def __init__(self): self.toolbar.connect_bundle( "base", PerformanceConnection(self.toolbar.components, self) ) + self.toolbar.components.add_safe( + "text", + MaterialIconAction( + "text_fields", + tooltip="Test Text Action", + checkable=True, + label_text="text", + text_position="under", + ), + ) self.toolbar.show_bundles(["performance", "plot_export"]) self.toolbar.get_bundle("performance").add_action("save") + self.toolbar.get_bundle("performance").add_action("text") self.toolbar.refresh() + # Timer to disable and enable text button each 2s + self.timer = QTimer() + self.timer.timeout.connect(self.toggle_text_action) + self.timer.start(2000) + + def toggle_text_action(self): + text_action = self.toolbar.components.get_action("text") + if text_action.action.isEnabled(): + text_action.action.setEnabled(False) + else: + text_action.action.setEnabled(True) + def enable_fps_monitor(self, enabled: bool): """ Example method to enable or disable FPS monitoring. From 16d88fe8df538bb54e2e961b71098f6d87734df6 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 17 Oct 2025 13:40:42 +0200 Subject: [PATCH 102/161] fix(jupyter_console): cleanup --- .../jupyter_console/jupyter_console.py | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py b/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py index 24234db64..e53226898 100644 --- a/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py +++ b/bec_widgets/widgets/editors/jupyter_console/jupyter_console.py @@ -2,6 +2,7 @@ from qtconsole.inprocess import QtInProcessKernelManager from qtconsole.manager import QtKernelManager from qtconsole.rich_jupyter_widget import RichJupyterWidget +from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QMainWindow @@ -9,10 +10,10 @@ class BECJupyterConsole(RichJupyterWidget): # pragma: no cover: def __init__(self, inprocess: bool = False): super().__init__() - self.inprocess = None - self.client = None + self.inprocess = inprocess + self.ipyclient = None - self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=inprocess) + self.kernel_manager, self.kernel_client = self._init_kernel(inprocess=self.inprocess) self.set_default_style("linux") self._init_bec() @@ -35,14 +36,13 @@ def _init_bec(self): self._init_bec_kernel() def _init_bec_inprocess(self): - self.client = BECIPythonClient() - self.client.start() - + self.ipyclient = BECIPythonClient() + self.ipyclient.start() self.kernel_manager.kernel.shell.push( { - "bec": self.client, - "dev": self.client.device_manager.devices, - "scans": self.client.scans, + "bec": self.ipyclient, + "dev": self.ipyclient.device_manager.devices, + "scans": self.ipyclient.scans, } ) @@ -57,20 +57,47 @@ def _init_bec_kernel(self): """ ) + def _cleanup_bec(self): + if getattr(self, "ipyclient", None) is not None and self.inprocess is True: + self.ipyclient.shutdown() + self.ipyclient = None + def shutdown_kernel(self): + """ + Shutdown the Jupyter kernel and clean up resources. + """ + self._cleanup_bec() self.kernel_client.stop_channels() self.kernel_manager.shutdown_kernel() + self.kernel_client = None + self.kernel_manager = None def closeEvent(self, event): self.shutdown_kernel() + event.accept() + super().closeEvent(event) + + +class JupyterConsoleWindow(QMainWindow): # pragma: no cover: + def __init__(self, inprocess: bool = True, parent=None): + super().__init__(parent) + self.console = BECJupyterConsole(inprocess=inprocess) + self.setCentralWidget(self.console) + self.setAttribute(Qt.WA_DeleteOnClose, True) + + def closeEvent(self, event): + # Explicitly close the console so its own closeEvent runs + if getattr(self, "console", None) is not None: + self.console.close() + event.accept() + super().closeEvent(event) if __name__ == "__main__": # pragma: no cover import sys app = QApplication(sys.argv) - win = QMainWindow() - win.setCentralWidget(BECJupyterConsole(True)) + win = JupyterConsoleWindow(inprocess=True) win.show() sys.exit(app.exec_()) From cb233b877d4896ca3f308c2410711098b7665932 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 17 Oct 2025 10:45:08 +0200 Subject: [PATCH 103/161] feat(jupyter_console_window): adjustment for general usage --- .../jupyter_console/jupyter_console_window.py | 477 +++++++++++++----- 1 file changed, 353 insertions(+), 124 deletions(-) diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index b5e925633..ed0dc0356 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -1,12 +1,23 @@ +from __future__ import annotations + +import ast +import importlib import os +from typing import Any, Dict import numpy as np import pyqtgraph as pg from bec_qthemes import material_icon +from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QApplication, + QComboBox, + QFrame, + QGridLayout, QGroupBox, QHBoxLayout, + QLabel, + QLineEdit, QPushButton, QSplitter, QTabWidget, @@ -14,148 +25,359 @@ QWidget, ) -from bec_widgets.utils import BECDispatcher +from bec_widgets import BECWidget +from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.widget_io import WidgetHierarchy as wh -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.dock import BECDockArea -from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole -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.plot_base import PlotBase -from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform -from bec_widgets.widgets.plots.waveform.waveform import Waveform class JupyterConsoleWindow(QWidget): # pragma: no cover: - """A widget that contains a Jupyter console linked to BEC Widgets with full API access (contains Qt and pyqtgraph API).""" + """A widget that contains a Jupyter console linked to BEC Widgets with full API access. + + Features: + - Add widgets dynamically from the UI (top-right panel) or from the console via `jc.add_widget(...)`. + - Add BEC widgets by registered type via a combo box or `jc.add_widget_by_type(...)`. + - Each added widget appears as a new tab in the left tab widget and is exposed in the console under the chosen shortcut. + - Hardcoded example tabs removed; two examples are added programmatically at startup in the __main__ block. + """ - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self._widgets_by_name: Dict[str, QWidget] = {} self._init_ui() - # console push + # expose helper API and basics in the inprocess console if self.console.inprocess is True: - self.console.kernel_manager.kernel.shell.push( - { - "np": np, - "pg": pg, - "wh": wh, - "dock": self.dock, - "im": self.im, - "ads": self.ads, - # "mi": self.mi, - # "mm": self.mm, - # "lm": self.lm, - # "btn1": self.btn1, - # "btn2": self.btn2, - # "btn3": self.btn3, - # "btn4": self.btn4, - # "btn5": self.btn5, - # "btn6": self.btn6, - # "pb": self.pb, - # "pi": self.pi, - "wf": self.wf, - # "scatter": self.scatter, - # "scatter_mi": self.scatter, - # "mwf": self.mwf, - } - ) + # A thin API wrapper so users have a stable, minimal surface in the console + class _ConsoleAPI: + def __init__(self, win: "JupyterConsoleWindow"): + self._win = win + + def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None): + """Add an existing QWidget as a new tab and expose it in the console under `shortcut`.""" + return self._win.add_widget(widget, shortcut, title=title) + + def add_widget_by_class_path( + self, + class_path: str, + shortcut: str, + kwargs: dict | None = None, + title: str | None = None, + ): + """Import a QWidget class from `class_path`, instantiate it, and add it.""" + return self._win.add_widget_by_class_path( + class_path, shortcut, kwargs=kwargs, title=title + ) + + def add_widget_by_type( + self, + widget_type: str, + shortcut: str, + kwargs: dict | None = None, + title: str | None = None, + ): + """Instantiate a registered BEC widget by type string and add it.""" + return self._win.add_widget_by_type( + widget_type, shortcut, kwargs=kwargs, title=title + ) + + def list_widgets(self): + return list(self._win._widgets_by_name.keys()) + + def get_widget(self, shortcut: str) -> QWidget | None: + return self._win._widgets_by_name.get(shortcut) + + def available_widgets(self): + return list(widget_handler.widget_classes.keys()) + + self.jc = _ConsoleAPI(self) + self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh}) def _init_ui(self): self.layout = QHBoxLayout(self) - # Horizontal splitter + # Horizontal splitter: left = widgets tabs, right = console + add-widget panel splitter = QSplitter(self) self.layout.addWidget(splitter) - tab_widget = QTabWidget(splitter) - - first_tab = QWidget() - first_tab_layout = QVBoxLayout(first_tab) - self.dock = BECDockArea(gui_id="dock") - first_tab_layout.addWidget(self.dock) - tab_widget.addTab(first_tab, "Dock Area") - - # third_tab = QWidget() - # third_tab_layout = QVBoxLayout(third_tab) - # self.lm = LayoutManagerWidget() - # third_tab_layout.addWidget(self.lm) - # tab_widget.addTab(third_tab, "Layout Manager Widget") - # - # fourth_tab = QWidget() - # fourth_tab_layout = QVBoxLayout(fourth_tab) - # self.pb = PlotBase() - # self.pi = self.pb.plot_item - # fourth_tab_layout.addWidget(self.pb) - # tab_widget.addTab(fourth_tab, "PlotBase") - # - # tab_widget.setCurrentIndex(3) - # - group_box = QGroupBox("Jupyter Console", splitter) - group_box_layout = QVBoxLayout(group_box) + # Left: tabs that will host dynamically added widgets + self.tab_widget = QTabWidget(splitter) + + # Right: console area with an add-widget mini panel on top + right_panel = QGroupBox("Jupyter Console", splitter) + right_layout = QVBoxLayout(right_panel) + right_layout.setContentsMargins(6, 12, 6, 6) + + # Add-widget mini panel + add_panel = QFrame(right_panel) + shape = QFrame.Shape.StyledPanel # PySide6 style enums + add_panel.setFrameShape(shape) + add_grid = QGridLayout(add_panel) + add_grid.setContentsMargins(8, 8, 8, 8) + add_grid.setHorizontalSpacing(8) + add_grid.setVerticalSpacing(6) + + instr = QLabel( + "Add a widget by class path or choose a registered BEC widget type," + " and expose it in the console under a shortcut.\n" + "Example class path: bec_widgets.widgets.plots.waveform.waveform.Waveform" + ) + instr.setWordWrap(True) + add_grid.addWidget(instr, 0, 0, 1, 2) + + # Registered widget selector + reg_label = QLabel("Registered") + reg_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.registry_combo = QComboBox(add_panel) + self.registry_combo.setEditable(False) + self.refresh_btn = QPushButton("Refresh") + reg_row = QHBoxLayout() + reg_row.addWidget(self.registry_combo) + reg_row.addWidget(self.refresh_btn) + add_grid.addWidget(reg_label, 1, 0) + add_grid.addLayout(reg_row, 1, 1) + + # Class path entry + class_label = QLabel("Class") + class_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.class_path_edit = QLineEdit(add_panel) + self.class_path_edit.setPlaceholderText("Fully-qualified class path (e.g. pkg.mod.Class)") + add_grid.addWidget(class_label, 2, 0) + add_grid.addWidget(self.class_path_edit, 2, 1) + + # Shortcut + shortcut_label = QLabel("Shortcut") + shortcut_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.shortcut_edit = QLineEdit(add_panel) + self.shortcut_edit.setPlaceholderText("Shortcut in console (variable name)") + add_grid.addWidget(shortcut_label, 3, 0) + add_grid.addWidget(self.shortcut_edit, 3, 1) + + # Kwargs + kwargs_label = QLabel("Kwargs") + kwargs_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.kwargs_edit = QLineEdit(add_panel) + self.kwargs_edit.setPlaceholderText( + 'Optional kwargs as dict literal, e.g. {"popups": True}' + ) + add_grid.addWidget(kwargs_label, 4, 0) + add_grid.addWidget(self.kwargs_edit, 4, 1) + + # Title + title_label = QLabel("Title") + title_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.title_edit = QLineEdit(add_panel) + self.title_edit.setPlaceholderText("Optional tab title (defaults to Shortcut or Class)") + add_grid.addWidget(title_label, 5, 0) + add_grid.addWidget(self.title_edit, 5, 1) + + # Buttons + btn_row = QHBoxLayout() + self.add_btn = QPushButton("Add by class path") + self.add_btn.clicked.connect(self._on_add_widget_clicked) + self.add_reg_btn = QPushButton("Add registered") + self.add_reg_btn.clicked.connect(self._on_add_registered_clicked) + btn_row.addStretch(1) + btn_row.addWidget(self.add_reg_btn) + btn_row.addWidget(self.add_btn) + add_grid.addLayout(btn_row, 6, 0, 1, 2) + + # Make the second column expand + add_grid.setColumnStretch(0, 0) + add_grid.setColumnStretch(1, 1) + + # Console widget self.console = BECJupyterConsole(inprocess=True) - group_box_layout.addWidget(self.console) - # - # # Some buttons for layout testing - # self.btn1 = QPushButton("Button 1") - # self.btn2 = QPushButton("Button 2") - # self.btn3 = QPushButton("Button 3") - # self.btn4 = QPushButton("Button 4") - # self.btn5 = QPushButton("Button 5") - # self.btn6 = QPushButton("Button 6") - # - fifth_tab = QWidget() - fifth_tab_layout = QVBoxLayout(fifth_tab) - self.wf = Waveform() - fifth_tab_layout.addWidget(self.wf) - tab_widget.addTab(fifth_tab, "Waveform Next Gen") - # - sixth_tab = QWidget() - sixth_tab_layout = QVBoxLayout(sixth_tab) - self.im = Image(popups=True) - self.mi = self.im.main_image - sixth_tab_layout.addWidget(self.im) - tab_widget.addTab(sixth_tab, "Image Next Gen") - tab_widget.setCurrentIndex(1) - # - 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) - # self.mm = MotorMap() - # eighth_tab_layout.addWidget(self.mm) - # tab_widget.addTab(eighth_tab, "Motor Map") - # tab_widget.setCurrentIndex(7) - # - # ninth_tab = QWidget() - # ninth_tab_layout = QVBoxLayout(ninth_tab) - # self.mwf = MultiWaveform() - # ninth_tab_layout.addWidget(self.mwf) - # tab_widget.addTab(ninth_tab, "MultiWaveform") - # tab_widget.setCurrentIndex(8) - # - # # add stuff to the new Waveform widget - # self._init_waveform() - # - # self.setWindowTitle("Jupyter Console Window") - - def _init_waveform(self): - self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel") - self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel") + + # Vertical splitter between add panel and console + right_splitter = QSplitter(Qt.Vertical, right_panel) + right_splitter.addWidget(add_panel) + right_splitter.addWidget(self.console) + right_splitter.setStretchFactor(0, 0) + right_splitter.setStretchFactor(1, 1) + right_splitter.setSizes([300, 600]) + + # Put splitter into the right group box + right_layout.addWidget(right_splitter) + + # Populate registry on startup + self._populate_registry_widgets() + + def _populate_registry_widgets(self): + try: + widget_handler.update_available_widgets() + items = sorted(widget_handler.widget_classes.keys()) + except Exception as exc: + print(f"Failed to load registered widgets: {exc}") + items = [] + self.registry_combo.clear() + self.registry_combo.addItems(items) + + def _on_add_widget_clicked(self): + class_path = self.class_path_edit.text().strip() + shortcut = self.shortcut_edit.text().strip() + kwargs_text = self.kwargs_edit.text().strip() + title = self.title_edit.text().strip() or None + + if not class_path or not shortcut: + print("Please provide both class path and shortcut.") + return + + kwargs: dict | None = None + if kwargs_text: + try: + parsed = ast.literal_eval(kwargs_text) + if isinstance(parsed, dict): + kwargs = parsed + else: + print("Kwargs must be a Python dict literal, ignoring input.") + except Exception as exc: + print(f"Failed to parse kwargs: {exc}") + + try: + widget = self._instantiate_from_class_path(class_path, kwargs=kwargs) + except Exception as exc: + print(f"Failed to instantiate {class_path}: {exc}") + return + + try: + self.add_widget(widget, shortcut, title=title) + except Exception as exc: + print(f"Failed to add widget: {exc}") + return + + # focus the newly added tab + idx = self.tab_widget.count() - 1 + if idx >= 0: + self.tab_widget.setCurrentIndex(idx) + + def _on_add_registered_clicked(self): + widget_type = self.registry_combo.currentText().strip() + shortcut = self.shortcut_edit.text().strip() + kwargs_text = self.kwargs_edit.text().strip() + title = self.title_edit.text().strip() or None + + if not widget_type or not shortcut: + print("Please select a registered widget and provide a shortcut.") + return + + kwargs: dict | None = None + if kwargs_text: + try: + parsed = ast.literal_eval(kwargs_text) + if isinstance(parsed, dict): + kwargs = parsed + else: + print("Kwargs must be a Python dict literal, ignoring input.") + except Exception as exc: + print(f"Failed to parse kwargs: {exc}") + + try: + self.add_widget_by_type(widget_type, shortcut, kwargs=kwargs, title=title) + except Exception as exc: + print(f"Failed to add registered widget: {exc}") + return + + # focus the newly added tab + idx = self.tab_widget.count() - 1 + if idx >= 0: + self.tab_widget.setCurrentIndex(idx) + + def _instantiate_from_class_path(self, class_path: str, kwargs: dict | None = None) -> QWidget: + module_path, _, class_name = class_path.rpartition(".") + if not module_path or not class_name: + raise ValueError("class_path must be of the form 'package.module.Class'") + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + if kwargs is None: + obj = cls() + else: + obj = cls(**kwargs) + if not isinstance(obj, QWidget): + raise TypeError(f"Instantiated object from {class_path} is not a QWidget: {type(obj)}") + return obj + + def add_widget(self, widget: QWidget, shortcut: str, title: str | None = None) -> QWidget: + """Add a QWidget as a new tab and expose it in the Jupyter console. + + - widget: a QWidget instance to host in a new tab + - shortcut: variable name used in the console to access it + - title: optional tab title (defaults to shortcut or class name) + """ + if not isinstance(widget, QWidget): + raise TypeError("widget must be a QWidget instance") + if not shortcut or not shortcut.isidentifier(): + raise ValueError("shortcut must be a valid Python identifier") + if shortcut in self._widgets_by_name: + raise ValueError(f"A widget with shortcut '{shortcut}' already exists") + if self.console.inprocess is not True: + raise RuntimeError("Adding widgets and exposing them requires inprocess console") + + tab_title = title or shortcut or widget.__class__.__name__ + self.tab_widget.addTab(widget, tab_title) + self._widgets_by_name[shortcut] = widget + + # Expose in console under the given shortcut + self._push_to_console({shortcut: widget}) + return widget + + def add_widget_by_class_path( + self, class_path: str, shortcut: str, kwargs: dict | None = None, title: str | None = None + ) -> QWidget: + widget = self._instantiate_from_class_path(class_path, kwargs=kwargs) + return self.add_widget(widget, shortcut, title=title) + + def add_widget_by_type( + self, widget_type: str, shortcut: str, kwargs: dict | None = None, title: str | None = None + ) -> QWidget: + """Instantiate a registered BEC widget by its type string and add it as a tab. + + If kwargs does not contain `object_name`, it will default to the provided shortcut. + """ + # Ensure registry is loaded + widget_handler.update_available_widgets() + cls = widget_handler.widget_classes.get(widget_type) + if cls is None: + raise ValueError(f"Unknown registered widget type: {widget_type}") + + if kwargs is None: + kwargs = {"object_name": shortcut} + else: + kwargs = dict(kwargs) + kwargs.setdefault("object_name", shortcut) + + # Instantiate and add + widget = cls(**kwargs) + if not isinstance(widget, QWidget): + raise TypeError( + f"Instantiated object for type '{widget_type}' is not a QWidget: {type(widget)}" + ) + return self.add_widget(widget, shortcut, title=title) + + def _push_to_console(self, mapping: Dict[str, Any]): + """Push Python objects into the inprocess kernel user namespace.""" + if self.console.inprocess is True: + self.console.kernel_manager.kernel.shell.push(mapping) + else: + raise RuntimeError("Can only push variables when using inprocess kernel") def closeEvent(self, event): """Override to handle things when main window is closed.""" - self.dock.cleanup() - self.dock.close() + # clean up any widgets that might have custom cleanup + try: + # call cleanup on known containers if present + dock = self._widgets_by_name.get("dock") + if isinstance(dock, BECDockArea): + dock.cleanup() + dock.close() + except Exception: + pass + + # Ensure the embedded kernel and BEC client are shut down before window teardown + self.console.shutdown_kernel() self.console.close() super().closeEvent(event) @@ -175,13 +397,20 @@ def closeEvent(self, event): icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True) app.setWindowIcon(icon) - bec_dispatcher = BECDispatcher(gui_id="jupyter_console") - client = bec_dispatcher.client - client.start() - win = JupyterConsoleWindow() + + # Examples: add two widgets programmatically to demonstrate usage + try: + win.add_widget_by_type("Waveform", shortcut="wf") + except Exception as exc: + print(f"Example add failed (Waveform by type): {exc}") + + try: + win.add_widget_by_type("Image", shortcut="im", kwargs={"popups": True}) + except Exception as exc: + print(f"Example add failed (Image by type): {exc}") + win.show() win.resize(1500, 800) - app.aboutToQuit.connect(win.close) sys.exit(app.exec_()) From 4dc128c64ad899c65506cc6afa7fe6c6344df623 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 30 Oct 2025 10:41:27 +0100 Subject: [PATCH 104/161] fix(actions): set default text position to 'under' --- bec_widgets/utils/toolbars/actions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 8d87e0320..dbeb937c3 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -51,11 +51,11 @@ def create_action_with_text(toolbar_action, toolbar: QToolBar): btn.setDefaultAction(toolbar_action.action) btn.setAutoRaise(True) - if toolbar_action.text_position == "under": - btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) - else: + if toolbar_action.text_position == "beside": btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - + else: + btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + btn.setText(toolbar_action.label_text) toolbar.addWidget(btn) From b43abfafcdb6d615798efd78cdcd4559d8ef244a Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 15 Oct 2025 16:21:40 +0200 Subject: [PATCH 105/161] ci: install ttyd --- .github/actions/bw_install/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/bw_install/action.yml b/.github/actions/bw_install/action.yml index 548a278c3..5b7c08017 100644 --- a/.github/actions/bw_install/action.yml +++ b/.github/actions/bw_install/action.yml @@ -53,6 +53,7 @@ runs: sudo apt-get update sudo apt-get install -y libgl1 libegl1 x11-utils libxkbcommon-x11-0 libdbus-1-3 xvfb sudo apt-get -y install libnss3 libxdamage1 libasound2t64 libatomic1 libxcursor1 + sudo apt-get -y install ttyd - name: Install Python dependencies shell: bash From 7a43111de06fab75d3f6d3a9207973f5beb9b482 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 18 Aug 2025 16:47:16 +0200 Subject: [PATCH 106/161] feat: add developer view --- .../examples/developer_view/__init__.py | 0 .../examples/developer_view/developer_view.py | 433 ++++++++++++++++++ .../widgets/containers/explorer/explorer.py | 4 +- .../containers/explorer/macro_tree_widget.py | 408 +++++++++++++++++ .../containers/explorer/script_tree_widget.py | 29 +- .../widgets/editors/monaco/monaco_tab.py | 393 ++++++++++++++++ .../widgets/editors/monaco/monaco_widget.py | 115 ++++- .../editors/monaco/scan_control_dialog.py | 145 ++++++ .../utility/ide_explorer/ide_explorer.py | 191 +++++++- tests/unit_tests/test_tree_widget.py | 22 + 10 files changed, 1717 insertions(+), 23 deletions(-) create mode 100644 bec_widgets/examples/developer_view/__init__.py create mode 100644 bec_widgets/examples/developer_view/developer_view.py create mode 100644 bec_widgets/widgets/containers/explorer/macro_tree_widget.py create mode 100644 bec_widgets/widgets/editors/monaco/monaco_tab.py create mode 100644 bec_widgets/widgets/editors/monaco/scan_control_dialog.py create mode 100644 tests/unit_tests/test_tree_widget.py diff --git a/bec_widgets/examples/developer_view/__init__.py b/bec_widgets/examples/developer_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/examples/developer_view/developer_view.py b/bec_widgets/examples/developer_view/developer_view.py new file mode 100644 index 000000000..01f9dfbc4 --- /dev/null +++ b/bec_widgets/examples/developer_view/developer_view.py @@ -0,0 +1,433 @@ +import re +from typing import List + +import markdown +import PySide6QtAds as QtAds +from bec_lib.endpoints import MessageEndpoints +from bec_lib.script_executor import upload_script +from bec_qthemes import material_icon +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtCore import Qt, QTimer +from qtpy.QtGui import QKeySequence, QShortcut +from qtpy.QtWidgets import QSplitter, QTextEdit, QVBoxLayout, QWidget + +from bec_widgets import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock +from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + + +def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: + """ + Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. + Works for horizontal or vertical splitters and sets matching stretch factors. + """ + + def apply(): + n = splitter.count() + if n == 0: + return + w = list(weights[:n]) + [1] * max(0, n - len(weights)) + w = [max(0.0, float(x)) for x in w] + tot_w = sum(w) + if tot_w <= 0: + w = [1.0] * n + tot_w = float(n) + total_px = ( + splitter.width() if splitter.orientation() == Qt.Horizontal else splitter.height() + ) + if total_px < 2: + QTimer.singleShot(0, apply) + return + sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] + diff = total_px - sum(sizes) + if diff != 0: + idx = max(range(n), key=lambda i: w[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, wi in enumerate(w): + splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) + + QTimer.singleShot(0, apply) + + +def markdown_to_html(md_text: str) -> str: + """Convert Markdown with syntax highlighting to HTML (Qt-compatible).""" + + # Preprocess: convert consecutive >>> lines to Python code blocks + def replace_python_examples(match): + indent = match.group(1) + examples = match.group(2) + # Remove >>> prefix and clean up the code + lines = [] + for line in examples.strip().split("\n"): + line = line.strip() + if line.startswith(">>> "): + lines.append(line[4:]) # Remove '>>> ' + elif line.startswith(">>>"): + lines.append(line[3:]) # Remove '>>>' + code = "\n".join(lines) + + return f"{indent}```python\n{indent}{code}\n{indent}```" + + # Match one or more consecutive >>> lines (with same indentation) + pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)" + md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE) + + extensions = ["fenced_code", "codehilite", "tables", "sane_lists"] + html = markdown.markdown( + md_text, + extensions=extensions, + extension_configs={ + "codehilite": {"linenums": False, "guess_lang": False, "noclasses": True} + }, + output_format="html", + ) + + # Remove hardcoded background colors that conflict with themes + html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html) + html = re.sub(r"background: #[^;]*;", "", html) + + # Add CSS to force code blocks to wrap + css = """ + + """ + + return css + html + + +class DeveloperView(BECWidget, QWidget): + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + self.toolbar = ModularToolBar(self) + self.init_developer_toolbar() + self._root_layout.addWidget(self.toolbar) + + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self._root_layout.addWidget(self.dock_manager) + + # Initialize the widgets + self.explorer = IDEExplorer(self) + self.console = WebConsole(self) + self.terminal = WebConsole(self, startup_cmd="") + self.monaco = MonacoDock(self) + self.monaco.save_enabled.connect(self._on_save_enabled_update) + self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom") + self.signature_help = QTextEdit(self) + self.signature_help.setAcceptRichText(True) + self.signature_help.setReadOnly(True) + self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + opt = self.signature_help.document().defaultTextOption() + opt.setWrapMode(opt.WrapMode.WrapAnywhere) + self.signature_help.document().setDefaultTextOption(opt) + self.monaco.signature_help.connect( + lambda text: self.signature_help.setHtml(markdown_to_html(text)) + ) + + # Create the dock widgets + self.explorer_dock = QtAds.CDockWidget("Explorer", self) + self.explorer_dock.setWidget(self.explorer) + + self.console_dock = QtAds.CDockWidget("Console", self) + self.console_dock.setWidget(self.console) + + self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self) + self.monaco_dock.setWidget(self.monaco) + + self.terminal_dock = QtAds.CDockWidget("Terminal", self) + self.terminal_dock.setWidget(self.terminal) + + # Monaco will be central widget + self.dock_manager.setCentralWidget(self.monaco_dock) + + # Add the dock widgets to the dock manager + area_bottom = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock + ) + self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom) + + area_left = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock + ) + area_left.titleBar().setVisible(False) + + for dock in self.dock_manager.dockWidgets(): + # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea + # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same + dock.setFeature(CDockWidget.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetMovable, False) + + self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self) + self.plotting_ads_dock.setWidget(self.plotting_ads) + + self.signature_dock = QtAds.CDockWidget("Signature Help", self) + self.signature_dock.setWidget(self.signature_help) + + area_right = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock + ) + self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right) + + # Apply stretch after the layout is done + self.set_default_view([2, 5, 3], [7, 3]) + + # Connect editor signals + self.explorer.file_open_requested.connect(self._open_new_file) + + self.toolbar.show_bundles(["save", "execution", "settings"]) + + def init_developer_toolbar(self): + """Initialize the developer toolbar with necessary actions and widgets.""" + save_button = MaterialIconAction( + icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self + ) + save_button.action.triggered.connect(self.on_save) + self.toolbar.components.add_safe("save", save_button) + + save_as_button = MaterialIconAction( + icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self + ) + self.toolbar.components.add_safe("save_as", save_as_button) + + save_bundle = ToolbarBundle("save", self.toolbar.components) + save_bundle.add_action("save") + save_bundle.add_action("save_as") + self.toolbar.add_bundle(save_bundle) + + run_action = MaterialIconAction( + icon_name="play_arrow", + tooltip="Run current file", + label_text="Run", + filled=True, + parent=self, + ) + run_action.action.triggered.connect(self.on_execute) + self.toolbar.components.add_safe("run", run_action) + + stop_action = MaterialIconAction( + icon_name="stop", + tooltip="Stop current execution", + label_text="Stop", + filled=True, + parent=self, + ) + stop_action.action.triggered.connect(self.on_stop) + self.toolbar.components.add_safe("stop", stop_action) + + execution_bundle = ToolbarBundle("execution", self.toolbar.components) + execution_bundle.add_action("run") + execution_bundle.add_action("stop") + self.toolbar.add_bundle(execution_bundle) + + vim_action = MaterialIconAction( + icon_name="vim", + tooltip="Toggle Vim Mode", + label_text="Vim", + filled=True, + parent=self, + checkable=True, + ) + self.toolbar.components.add_safe("vim", vim_action) + vim_action.action.triggered.connect(self.on_vim_triggered) + + settings_bundle = ToolbarBundle("settings", self.toolbar.components) + settings_bundle.add_action("vim") + self.toolbar.add_bundle(settings_bundle) + + save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self) + save_shortcut.activated.connect(self.on_save) + save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self) + save_as_shortcut.activated.connect(self.on_save_as) + + ####### Default view has to be done with setting up splitters ######## + def set_default_view(self, horizontal_weights: list, vertical_weights: list): + """Apply initial weights to every horizontal and vertical splitter. + + Examples: + horizontal_weights = [1, 3, 2, 1] + vertical_weights = [3, 7] # top:bottom = 30:70 + """ + splitters_h = [] + splitters_v = [] + for splitter in self.findChildren(QSplitter): + if splitter.orientation() == Qt.Horizontal: + splitters_h.append(splitter) + elif splitter.orientation() == Qt.Vertical: + splitters_v.append(splitter) + + def apply_all(): + for s in splitters_h: + set_splitter_weights(s, horizontal_weights) + for s in splitters_v: + set_splitter_weights(s, vertical_weights) + + QTimer.singleShot(0, apply_all) + + def set_stretch(self, *, horizontal=None, vertical=None): + """Update splitter weights and re-apply to all splitters. + + Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + + def _open_new_file(self, file_name: str, scope: str): + self.monaco.open_file(file_name) + + # Set read-only mode for shared files + if "shared" in scope: + self.monaco.set_file_readonly(file_name, True) + + # Add appropriate icon based on file type + if "script" in scope: + # Use script icon for script files + icon = material_icon("script", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + elif "macro" in scope: + # Use function icon for macro files + icon = material_icon("function", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + + @SafeSlot() + def on_save(self): + self.monaco.save_file() + + @SafeSlot() + def on_save_as(self): + self.monaco.save_file(force_save_as=True) + + @SafeSlot() + def on_vim_triggered(self): + self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked()) + + @SafeSlot(bool) + def _on_save_enabled_update(self, enabled: bool): + self.toolbar.components.get_action("save").action.setEnabled(enabled) + self.toolbar.components.get_action("save_as").action.setEnabled(enabled) + + @SafeSlot() + def on_execute(self): + self.script_editor_tab = self.monaco.last_focused_editor + if not self.script_editor_tab: + return + self.current_script_id = upload_script( + self.client.connector, self.script_editor_tab.widget().get_text() + ) + self.console.write(f'bec._run_script("{self.current_script_id}")') + print(f"Uploaded script with ID: {self.current_script_id}") + + @SafeSlot() + def on_stop(self): + print("Stopping execution...") + + @property + def current_script_id(self): + return self._current_script_id + + @current_script_id.setter + def current_script_id(self, value): + if not isinstance(value, str): + raise ValueError("Script ID must be a string.") + self._current_script_id = value + self._update_subscription() + + def _update_subscription(self): + if self.current_script_id: + self.bec_dispatcher.connect_slot( + self.on_script_execution_info, + MessageEndpoints.script_execution_info(self.current_script_id), + ) + else: + self.bec_dispatcher.disconnect_slot( + self.on_script_execution_info, + MessageEndpoints.script_execution_info(self.current_script_id), + ) + + @SafeSlot(dict, dict) + def on_script_execution_info(self, content: dict, metadata: dict): + print(f"Script execution info: {content}") + current_lines = content.get("current_lines") + if not current_lines: + self.script_editor_tab.widget().clear_highlighted_lines() + return + line_number = current_lines[0] + self.script_editor_tab.widget().clear_highlighted_lines() + self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + from qtpy.QtWidgets import QApplication + + from bec_widgets.applications.main_app import BECMainApp + + app = QApplication(sys.argv) + apply_theme("dark") + + _app = BECMainApp() + developer_view = DeveloperView() + _app.add_view( + icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True + ) + _app.show() + # developer_view.show() + # developer_view.setWindowTitle("Developer View") + # developer_view.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/containers/explorer/explorer.py b/bec_widgets/widgets/containers/explorer/explorer.py index b780cbdee..25bff357b 100644 --- a/bec_widgets/widgets/containers/explorer/explorer.py +++ b/bec_widgets/widgets/containers/explorer/explorer.py @@ -18,8 +18,8 @@ class Explorer(BECWidget, QWidget): RPC = False PLUGIN = False - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) # Main layout self.main_layout = QVBoxLayout(self) diff --git a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py new file mode 100644 index 000000000..3b247d97f --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py @@ -0,0 +1,408 @@ +import ast +import os +from pathlib import Path +from typing import Any + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QModelIndex, QRect, Qt, Signal +from qtpy.QtGui import QPainter, QStandardItem, QStandardItemModel +from qtpy.QtWidgets import QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget + +from bec_widgets.utils.colors import get_theme_palette +from bec_widgets.utils.toolbars.actions import MaterialIconAction + +logger = bec_logger.logger + + +class MacroItemDelegate(QStyledItemDelegate): + """Custom delegate to show action buttons on hover for macro functions""" + + def __init__(self, parent=None): + super().__init__(parent) + self.hovered_index = QModelIndex() + self.macro_actions: list[Any] = [] + self.button_rects: list[QRect] = [] + self.current_macro_info = {} + + def add_macro_action(self, action: Any) -> None: + """Add an action for macro functions""" + self.macro_actions.append(action) + + def clear_actions(self) -> None: + """Remove all actions""" + self.macro_actions.clear() + + def paint(self, painter, option, index): + """Paint the item with action buttons on hover""" + # Paint the default item + super().paint(painter, option, index) + + # Early return if not hovering over this item + if index != self.hovered_index: + return + + # Only show actions for macro functions (not directories) + item = index.model().itemFromIndex(index) + if not item or not item.data(Qt.ItemDataRole.UserRole): + return + + macro_info = item.data(Qt.ItemDataRole.UserRole) + if not isinstance(macro_info, dict) or "function_name" not in macro_info: + return + + self.current_macro_info = macro_info + + if self.macro_actions: + self._draw_action_buttons(painter, option, self.macro_actions) + + def _draw_action_buttons(self, painter, option, actions: list[Any]): + """Draw action buttons on the right side""" + button_size = 18 + margin = 4 + spacing = 2 + + # Calculate total width needed for all buttons + total_width = len(actions) * button_size + (len(actions) - 1) * spacing + + # Clear previous button rects and create new ones + self.button_rects.clear() + + # Calculate starting position (right side of the item) + start_x = option.rect.right() - total_width - margin + current_x = start_x + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Get theme colors for better integration + palette = get_theme_palette() + button_bg = palette.button().color() + button_bg.setAlpha(150) # Semi-transparent + + for action in actions: + if not action.isVisible(): + continue + + # Calculate button position + button_rect = QRect( + current_x, + option.rect.top() + (option.rect.height() - button_size) // 2, + button_size, + button_size, + ) + self.button_rects.append(button_rect) + + # Draw button background + painter.setBrush(button_bg) + painter.setPen(palette.mid().color()) + painter.drawRoundedRect(button_rect, 3, 3) + + # Draw action icon + icon = action.icon() + if not icon.isNull(): + icon_rect = button_rect.adjusted(2, 2, -2, -2) + icon.paint(painter, icon_rect) + + # Move to next button position + current_x += button_size + spacing + + painter.restore() + + def editorEvent(self, event, model, option, index): + """Handle mouse events for action buttons""" + # Early return if not a left click + if not ( + event.type() == event.Type.MouseButtonPress + and event.button() == Qt.MouseButton.LeftButton + ): + return super().editorEvent(event, model, option, index) + + # Check which button was clicked + visible_actions = [action for action in self.macro_actions if action.isVisible()] + for i, button_rect in enumerate(self.button_rects): + if button_rect.contains(event.pos()) and i < len(visible_actions): + # Trigger the action + visible_actions[i].trigger() + return True + + return super().editorEvent(event, model, option, index) + + def set_hovered_index(self, index): + """Set the currently hovered index""" + self.hovered_index = index + + +class MacroTreeWidget(QWidget): + """A tree widget that displays macro functions from Python files""" + + macro_selected = Signal(str, str) # Function name, file path + macro_open_requested = Signal(str, str) # Function name, file path + + def __init__(self, parent=None): + super().__init__(parent) + + # Create layout + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Create tree view + self.tree = QTreeView() + self.tree.setHeaderHidden(True) + self.tree.setRootIsDecorated(True) + + # Disable editing to prevent renaming on double-click + self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers) + + # Enable mouse tracking for hover effects + self.tree.setMouseTracking(True) + + # Create model for macro functions + self.model = QStandardItemModel() + self.tree.setModel(self.model) + + # Create and set custom delegate + self.delegate = MacroItemDelegate(self.tree) + self.tree.setItemDelegate(self.delegate) + + # Add default open button for macros + action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self) + action.action.triggered.connect(self._on_macro_open_requested) + self.delegate.add_macro_action(action.action) + + # Apply BEC styling + self._apply_styling() + + # Macro specific properties + self.directory = None + + # Connect signals + self.tree.clicked.connect(self._on_item_clicked) + self.tree.doubleClicked.connect(self._on_item_double_clicked) + + # Install event filter for hover tracking + self.tree.viewport().installEventFilter(self) + + # Add to layout + layout.addWidget(self.tree) + + def _apply_styling(self): + """Apply styling to the tree widget""" + # Get theme colors for subtle tree lines + palette = get_theme_palette() + subtle_line_color = palette.mid().color() + subtle_line_color.setAlpha(80) + + # Standard editable styling + opacity_modifier = "" + cursor_style = "" + + # pylint: disable=f-string-without-interpolation + tree_style = f""" + QTreeView {{ + border: none; + outline: 0; + show-decoration-selected: 0; + {opacity_modifier} + {cursor_style} + }} + QTreeView::branch {{ + border-image: none; + background: transparent; + }} + + QTreeView::item {{ + border: none; + padding: 0px; + margin: 0px; + }} + QTreeView::item:hover {{ + background: palette(midlight); + border: none; + padding: 0px; + margin: 0px; + text-decoration: none; + }} + QTreeView::item:selected {{ + background: palette(highlight); + color: palette(highlighted-text); + }} + QTreeView::item:selected:hover {{ + background: palette(highlight); + }} + """ + + self.tree.setStyleSheet(tree_style) + + def eventFilter(self, obj, event): + """Handle mouse move events for hover tracking""" + # Early return if not the tree viewport + if obj != self.tree.viewport(): + return super().eventFilter(obj, event) + + if event.type() == event.Type.MouseMove: + index = self.tree.indexAt(event.pos()) + if index.isValid(): + self.delegate.set_hovered_index(index) + else: + self.delegate.set_hovered_index(QModelIndex()) + self.tree.viewport().update() + return super().eventFilter(obj, event) + + if event.type() == event.Type.Leave: + self.delegate.set_hovered_index(QModelIndex()) + self.tree.viewport().update() + return super().eventFilter(obj, event) + + return super().eventFilter(obj, event) + + def set_directory(self, directory): + """Set the macros directory and scan for macro functions""" + self.directory = directory + + # Early return if directory doesn't exist + if not directory or not os.path.exists(directory): + return + + self._scan_macro_functions() + + def _scan_macro_functions(self): + """Scan the directory for Python files and extract macro functions""" + self.model.clear() + self.model.setHorizontalHeaderLabels(["Macros"]) + + if not self.directory or not os.path.exists(self.directory): + return + + # Get all Python files in the directory + python_files = list(Path(self.directory).glob("*.py")) + + for py_file in python_files: + # Skip files starting with underscore + if py_file.name.startswith("_"): + continue + + try: + functions = self._extract_functions_from_file(py_file) + if functions: + # Create a file node + file_item = QStandardItem(py_file.stem) + file_item.setData( + {"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole + ) + + # Add function nodes + for func_name, func_info in functions.items(): + func_item = QStandardItem(func_name) + func_data = { + "function_name": func_name, + "file_path": str(py_file), + "line_number": func_info.get("line_number", 1), + "type": "function", + } + func_item.setData(func_data, Qt.ItemDataRole.UserRole) + file_item.appendRow(func_item) + + self.model.appendRow(file_item) + except Exception as e: + logger.warning(f"Failed to parse {py_file}: {e}") + + self.tree.expandAll() + + def _extract_functions_from_file(self, file_path: Path) -> dict: + """Extract function definitions from a Python file""" + functions = {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Parse the AST + tree = ast.parse(content) + + # Only get top-level function definitions + for node in tree.body: + if isinstance(node, ast.FunctionDef): + functions[node.name] = { + "line_number": node.lineno, + "docstring": ast.get_docstring(node) or "", + } + + except Exception as e: + logger.warning(f"Failed to parse {file_path}: {e}") + + return functions + + def _on_item_clicked(self, index: QModelIndex): + """Handle item clicks""" + item = self.model.itemFromIndex(index) + if not item: + return + + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + + if data.get("type") == "function": + function_name = data.get("function_name") + file_path = data.get("file_path") + if function_name and file_path: + logger.info(f"Macro function selected: {function_name} in {file_path}") + self.macro_selected.emit(function_name, file_path) + + def _on_item_double_clicked(self, index: QModelIndex): + """Handle item double-clicks""" + item = self.model.itemFromIndex(index) + if not item: + return + + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + + if data.get("type") == "function": + function_name = data.get("function_name") + file_path = data.get("file_path") + if function_name and file_path: + logger.info( + f"Macro open requested via double-click: {function_name} in {file_path}" + ) + self.macro_open_requested.emit(function_name, file_path) + + def _on_macro_open_requested(self): + """Handle macro open action triggered""" + logger.info("Macro open requested") + # Early return if no hovered item + if not self.delegate.hovered_index.isValid(): + return + + macro_info = self.delegate.current_macro_info + if not macro_info or macro_info.get("type") != "function": + return + + function_name = macro_info.get("function_name") + file_path = macro_info.get("file_path") + if function_name and file_path: + self.macro_open_requested.emit(function_name, file_path) + + def add_macro_action(self, action: Any) -> None: + """Add an action for macro items""" + self.delegate.add_macro_action(action) + + def clear_actions(self) -> None: + """Remove all actions from items""" + self.delegate.clear_actions() + + def refresh(self): + """Refresh the tree view""" + if self.directory is None: + return + self._scan_macro_functions() + + def expand_all(self): + """Expand all items in the tree""" + self.tree.expandAll() + + def collapse_all(self): + """Collapse all items in the tree""" + self.tree.collapseAll() diff --git a/bec_widgets/widgets/containers/explorer/script_tree_widget.py b/bec_widgets/widgets/containers/explorer/script_tree_widget.py index 86cec3493..6c8ed5c8f 100644 --- a/bec_widgets/widgets/containers/explorer/script_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/script_tree_widget.py @@ -3,7 +3,7 @@ from bec_lib.logger import bec_logger from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal -from qtpy.QtGui import QAction, QPainter +from qtpy.QtGui import QPainter from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette @@ -15,19 +15,20 @@ class FileItemDelegate(QStyledItemDelegate): """Custom delegate to show action buttons on hover""" - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, tree_widget): + super().__init__(tree_widget) + self.setObjectName("file_item_delegate") self.hovered_index = QModelIndex() - self.file_actions: list[QAction] = [] - self.dir_actions: list[QAction] = [] - self.button_rects: list[QRect] = [] + self.file_actions = [] + self.dir_actions = [] + self.button_rects = [] self.current_file_path = "" - def add_file_action(self, action: QAction) -> None: + def add_file_action(self, action) -> None: """Add an action for files""" self.file_actions.append(action) - def add_dir_action(self, action: QAction) -> None: + def add_dir_action(self, action) -> None: """Add an action for directories""" self.dir_actions.append(action) @@ -67,7 +68,7 @@ def paint(self, painter, option, index): if actions: self._draw_action_buttons(painter, option, actions) - def _draw_action_buttons(self, painter, option, actions: list[QAction]): + def _draw_action_buttons(self, painter, option, actions): """Draw action buttons on the right side""" button_size = 18 margin = 4 @@ -229,12 +230,18 @@ def _apply_styling(self): subtle_line_color = palette.mid().color() subtle_line_color.setAlpha(80) + # Standard editable styling + opacity_modifier = "" + cursor_style = "" + # pylint: disable=f-string-without-interpolation tree_style = f""" QTreeView {{ border: none; outline: 0; show-decoration-selected: 0; + {opacity_modifier} + {cursor_style} }} QTreeView::branch {{ border-image: none; @@ -357,11 +364,11 @@ def _on_file_open_requested(self): self.file_open_requested.emit(file_path) - def add_file_action(self, action: QAction) -> None: + def add_file_action(self, action) -> None: """Add an action for file items""" self.delegate.add_file_action(action) - def add_dir_action(self, action: QAction) -> None: + def add_dir_action(self, action) -> None: """Add an action for directory items""" self.delegate.add_dir_action(action) diff --git a/bec_widgets/widgets/editors/monaco/monaco_tab.py b/bec_widgets/widgets/editors/monaco/monaco_tab.py new file mode 100644 index 000000000..d4b62e78c --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/monaco_tab.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import os +import pathlib +from typing import Any, cast + +import PySide6QtAds as QtAds +from bec_lib.logger import bec_logger +from PySide6QtAds import CDockWidget +from qtpy.QtCore import QEvent, QTimer, Signal +from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget + +from bec_widgets import BECWidget +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +logger = bec_logger.logger + + +class MonacoDock(BECWidget, QWidget): + """ + MonacoDock is a dock widget that contains Monaco editor instances. + It is used to manage multiple Monaco editors in a dockable interface. + """ + + focused_editor = Signal(object) # Emitted when the focused editor changes + save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled + signature_help = Signal(str) # Emitted when signature help is requested + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + self.dock_manager = QtAds.CDockManager(self) + self.dock_manager.setStyleSheet("") + self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event) + self._root_layout.addWidget(self.dock_manager) + self.dock_manager.installEventFilter(self) + self._last_focused_editor: MonacoWidget | None = None + self.focused_editor.connect(self._on_last_focused_editor_changed) + self.add_editor() + self._open_files = {} + + def _create_editor(self): + widget = MonacoWidget(self) + widget.save_enabled.connect(self.save_enabled.emit) + widget.editor.signature_help_triggered.connect(self._on_signature_change) + count = len(self.dock_manager.dockWidgets()) + dock = CDockWidget(f"Untitled_{count + 1}") + dock.setWidget(widget) + + # Connect to modification status changes to update tab titles + widget.save_enabled.connect( + lambda modified: self._update_tab_title_for_modification(dock, modified) + ) + + dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True) + dock.setFeature(CDockWidget.CustomCloseHandling, True) + dock.setFeature(CDockWidget.DockWidgetClosable, True) + dock.setFeature(CDockWidget.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetMovable, True) + + dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget)) + + return dock + + @property + def last_focused_editor(self) -> CDockWidget | None: + """ + Get the last focused editor. + """ + return self._last_focused_editor + + @last_focused_editor.setter + def last_focused_editor(self, editor: CDockWidget | None): + self._last_focused_editor = editor + self.focused_editor.emit(editor) + + def _on_last_focused_editor_changed(self, editor: CDockWidget | None): + if editor is None: + self.save_enabled.emit(False) + return + + widget = cast(MonacoWidget, editor.widget()) + if widget.modified: + logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}") + self.save_enabled.emit(widget.modified) + + def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool): + """Update the tab title to show modification status with a dot indicator.""" + current_title = dock.windowTitle() + + # Remove existing modification indicator (dot and space) + if current_title.startswith("• "): + base_title = current_title[2:] # Remove "• " + else: + base_title = current_title + + # Add or remove the modification indicator + if modified: + new_title = f"• {base_title}" + else: + new_title = base_title + + dock.setWindowTitle(new_title) + + def _on_signature_change(self, signature: dict): + signatures = signature.get("signatures", []) + if not signatures: + self.signature_help.emit("") + return + + active_sig = signatures[signature.get("activeSignature", 0)] + active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param + + # Get signature label and documentation + label = active_sig.get("label", "") + doc_obj = active_sig.get("documentation", {}) + documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj) + + # Format the markdown output + markdown = f"```python\n{label}\n```\n\n{documentation}" + self.signature_help.emit(markdown) + + def _on_focus_event(self, old_widget, new_widget) -> None: + # Track focus events for the dock widget + widget = new_widget.widget() + if isinstance(widget, MonacoWidget): + self.last_focused_editor = new_widget + + def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget): + # Cast widget to MonacoWidget since we know that's what it is + monaco_widget = cast(MonacoWidget, widget) + + # Check if we have unsaved changes + if monaco_widget.modified: + # Prompt the user to save changes + response = QMessageBox.question( + self, + "Unsaved Changes", + "You have unsaved changes. Do you want to save them?", + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.Cancel, + ) + if response == QMessageBox.StandardButton.Yes: + self.save_file(monaco_widget) + elif response == QMessageBox.StandardButton.Cancel: + return + + # Count all editor docks managed by this dock manager + total = len(self.dock_manager.dockWidgets()) + if total <= 1: + # Do not remove the last dock; just wipe its editor content + # Temporarily disable read-only mode if the editor is read-only + # so we can clear the content for reuse + monaco_widget.set_readonly(False) + monaco_widget.set_text("") + dock.setWindowTitle("Untitled") + dock.setTabToolTip("Untitled") + return + + # Otherwise, proceed to close and delete the dock + monaco_widget.close() + dock.closeDockWidget() + dock.deleteDockWidget() + if self.last_focused_editor is dock: + self.last_focused_editor = None + # After topology changes, make sure single-tab areas get a plus button + QTimer.singleShot(0, self._scan_and_fix_areas) + + def _ensure_area_plus(self, area): + if area is None: + return + # Only add once per area + if getattr(area, "_monaco_plus_btn", None) is not None: + return + # If the area has exactly one tab, inject a + button next to the tab bar + try: + tabbar = area.titleBar().tabBar() + count = tabbar.count() if hasattr(tabbar, "count") else 1 + except Exception: + count = 1 + if count >= 1: + plus_btn = QToolButton(area) + plus_btn.setText("+") + plus_btn.setToolTip("New Monaco Editor") + plus_btn.setAutoRaise(True) + tb = area.titleBar() + idx = tb.indexOf(tb.tabBar()) + tb.insertWidget(idx + 1, plus_btn) + plus_btn.clicked.connect(lambda: self.add_editor(area)) + # pylint: disable=protected-access + area._monaco_plus_btn = plus_btn + + def _scan_and_fix_areas(self): + # Find all dock areas under this manager and ensure each single-tab area has a plus button + areas = self.dock_manager.findChildren(QtAds.CDockAreaWidget) + for a in areas: + self._ensure_area_plus(a) + + def eventFilter(self, obj, event): + # Track dock manager events + if obj is self.dock_manager and event.type() in ( + QEvent.Type.ChildAdded, + QEvent.Type.ChildRemoved, + QEvent.Type.LayoutRequest, + ): + QTimer.singleShot(0, self._scan_and_fix_areas) + + return super().eventFilter(obj, event) + + def add_editor( + self, area: Any | None = None, title: str | None = None, tooltip: str | None = None + ): # Any as qt ads does not return a proper type + """ + Adds a new Monaco editor dock widget to the dock manager. + """ + new_dock = self._create_editor() + if title is not None: + new_dock.setWindowTitle(title) + if tooltip is not None: + new_dock.setTabToolTip(tooltip) + if area is None: + area_obj = self.dock_manager.addDockWidgetTab(QtAds.TopDockWidgetArea, new_dock) + self._ensure_area_plus(area_obj) + else: + # If an area is provided, add the dock to that area + self.dock_manager.addDockWidgetTabToArea(new_dock, area) + self._ensure_area_plus(area) + + QTimer.singleShot(0, self._scan_and_fix_areas) + return new_dock + + def open_file(self, file_name: str): + """ + Open a file in the specified area. If the file is already open, activate it. + """ + open_files = self._get_open_files() + if file_name in open_files: + dock = self._get_editor_dock(file_name) + if dock is not None: + dock.setAsCurrentTab() + return + + file = os.path.basename(file_name) + # If the current editor is empty, we reuse it + + # For now, the dock manager is only for the editor docks. We can therefore safely assume + # that all docks are editor docks. + dock_area = self.dock_manager.dockArea(0) + + editor_dock = dock_area.currentDockWidget() + editor_widget = editor_dock.widget() if editor_dock else None + if editor_widget: + editor_widget = cast(MonacoWidget, editor_dock.widget()) + if editor_widget.current_file is None and editor_widget.get_text() == "": + editor_dock.setWindowTitle(file) + editor_dock.setTabToolTip(file_name) + editor_widget.open_file(file_name) + return + + # File is not open, create a new editor + editor_dock = self.add_editor(title=file, tooltip=file_name) + widget = cast(MonacoWidget, editor_dock.widget()) + widget.open_file(file_name) + + def save_file( + self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True + ) -> None: + """ + Save the currently focused file. + + Args: + widget (MonacoWidget | None): The widget to save. If None, the last focused editor will be used. + force_save_as (bool): If True, the "Save As" dialog will be shown even if the file is already saved. + """ + if widget is None: + widget = self.last_focused_editor.widget() if self.last_focused_editor else None + if not widget: + return + if widget.current_file and not force_save_as: + if format_on_save and pathlib.Path(widget.current_file).suffix == ".py": + widget.format() + with open(widget.current_file, "w", encoding="utf-8") as f: + f.write(widget.get_text()) + # pylint: disable=protected-access + widget._original_content = widget.get_text() + widget.save_enabled.emit(False) + return + + # Save as option + save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)") + + if save_file: + # check if we have suffix specified + file = pathlib.Path(save_file[0]) + if file.suffix == "": + file = file.with_suffix(".py") + if format_on_save and file.suffix == ".py": + widget.format() + + text = widget.get_text() + with open(file, "w", encoding="utf-8") as f: + f.write(text) + widget._original_content = text + + # Update the current_file before emitting save_enabled to ensure proper tracking + widget._current_file = str(file) + widget.save_enabled.emit(False) + + # Find the dock widget containing this monaco widget and update title + for dock in self.dock_manager.dockWidgets(): + if dock.widget() == widget: + dock.setWindowTitle(file.name) + dock.setTabToolTip(str(file)) + break + + print(f"Save file called, last focused editor: {self.last_focused_editor}") + + def set_vim_mode(self, enabled: bool): + """ + Set Vim mode for all editor widgets. + + Args: + enabled (bool): Whether to enable or disable Vim mode. + """ + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + editor_widget.set_vim_mode_enabled(enabled) + + def _get_open_files(self) -> list[str]: + open_files = [] + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + if editor_widget.current_file is not None: + open_files.append(editor_widget.current_file) + return open_files + + def _get_editor_dock(self, file_name: str) -> CDockWidget | None: + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + if editor_widget.current_file == file_name: + return widget + return None + + def set_file_readonly(self, file_name: str, read_only: bool = True) -> bool: + """ + Set a specific file's editor to read-only mode. + + Args: + file_name (str): The file path to set read-only + read_only (bool): Whether to set read-only mode (default: True) + + Returns: + bool: True if the file was found and read-only was set, False otherwise + """ + editor_dock = self._get_editor_dock(file_name) + if editor_dock: + editor_widget = cast(MonacoWidget, editor_dock.widget()) + editor_widget.set_readonly(read_only) + return True + return False + + def set_file_icon(self, file_name: str, icon) -> bool: + """ + Set an icon for a specific file's tab. + + Args: + file_name (str): The file path to set icon for + icon: The QIcon to set on the tab + + Returns: + bool: True if the file was found and icon was set, False otherwise + """ + editor_dock = self._get_editor_dock(file_name) + if editor_dock: + editor_dock.setIcon(icon) + return True + return False + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + _dock = MonacoDock() + _dock.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index eb05cec70..1a28eec0e 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -1,11 +1,19 @@ +import os +import traceback from typing import Literal +import black +import isort import qtmonaco +from bec_lib.logger import bec_logger from qtpy.QtCore import Signal -from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget +from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_theme_name +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger class MonacoWidget(BECWidget, QWidget): @@ -14,6 +22,7 @@ class MonacoWidget(BECWidget, QWidget): """ text_changed = Signal(str) + save_enabled = Signal(bool) PLUGIN = True ICON_NAME = "code" USER_ACCESS = [ @@ -21,6 +30,7 @@ class MonacoWidget(BECWidget, QWidget): "get_text", "insert_text", "delete_line", + "open_file", "set_language", "get_language", "set_theme", @@ -47,7 +57,19 @@ def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs) layout.addWidget(self.editor) self.setLayout(layout) self.editor.text_changed.connect(self.text_changed.emit) + self.editor.text_changed.connect(self._check_save_status) self.editor.initialized.connect(self.apply_theme) + self.editor.initialized.connect(self._setup_context_menu) + self.editor.context_menu_action_triggered.connect(self._handle_context_menu_action) + self._current_file = None + self._original_content = "" + + @property + def current_file(self): + """ + Get the current file being edited. + """ + return self._current_file def apply_theme(self, theme: str | None = None) -> None: """ @@ -61,14 +83,17 @@ def apply_theme(self, theme: str | None = None) -> None: editor_theme = "vs" if theme == "light" else "vs-dark" self.set_theme(editor_theme) - def set_text(self, text: str) -> None: + def set_text(self, text: str, file_name: str | None = None) -> None: """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. + file_name (str): Set the file name """ - self.editor.set_text(text) + self._current_file = file_name + self._original_content = text + self.editor.set_text(text, uri=file_name) def get_text(self) -> str: """ @@ -76,6 +101,32 @@ def get_text(self) -> str: """ return self.editor.get_text() + def format(self) -> None: + """ + Format the current text in the Monaco editor. + """ + if not self.editor: + return + try: + content = self.get_text() + try: + formatted_content = black.format_str(content, mode=black.Mode(line_length=100)) + except Exception: # black.NothingChanged or other formatting exceptions + formatted_content = content + + config = isort.Config( + profile="black", + line_length=100, + multi_line_output=3, + include_trailing_comma=False, + known_first_party=["bec_widgets"], + ) + formatted_content = isort.code(formatted_content, config=config) + self.set_text(formatted_content, file_name=self.current_file) + except Exception: + content = traceback.format_exc() + logger.info(content) + def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None: """ Insert text at the current cursor position or at a specified line and column. @@ -96,6 +147,32 @@ def delete_line(self, line: int | None = None) -> None: """ self.editor.delete_line(line) + def open_file(self, file_name: str) -> None: + """ + Open a file in the editor. + + Args: + file_name (str): The path + file name of the file that needs to be displayed. + """ + + if not os.path.exists(file_name): + raise FileNotFoundError(f"The specified file does not exist: {file_name}") + + with open(file_name, "r", encoding="utf-8") as file: + content = file.read() + self.set_text(content, file_name=file_name) + + @property + def modified(self) -> bool: + """ + Check if the editor content has been modified. + """ + return self._original_content != self.get_text() + + @SafeSlot(str) + def _check_save_status(self, _text: str) -> None: + self.save_enabled.emit(self.modified) + def set_cursor( self, line: int, @@ -213,6 +290,36 @@ def get_lsp_header(self) -> str: """ return self.editor.get_lsp_header() + def _setup_context_menu(self): + """Setup custom context menu actions for the Monaco editor.""" + # Add the "Insert Scan" action to the context menu + self.editor.add_action("insert_scan", "Insert Scan", "python") + # Add the "Format Code" action to the context menu + self.editor.add_action("format_code", "Format Code", "python") + + def _handle_context_menu_action(self, action_id: str): + """Handle context menu action triggers.""" + if action_id == "insert_scan": + self._show_scan_control_dialog() + elif action_id == "format_code": + self._format_code() + + def _show_scan_control_dialog(self): + """Show the scan control dialog and insert the generated scan code.""" + # Import here to avoid circular imports + from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + + dialog = ScanControlDialog(self, client=self.client) + if dialog.exec_() == QDialog.DialogCode.Accepted: + scan_code = dialog.get_scan_code() + if scan_code: + # Insert the scan code at the current cursor position + self.insert_text(scan_code) + + def _format_code(self): + """Format the current code in the editor.""" + self.format() + if __name__ == "__main__": # pragma: no cover qapp = QApplication([]) @@ -234,7 +341,7 @@ def get_lsp_header(self) -> str: scans: Scans ####################################### -########## User Script ##################### +########## User Script ################ ####################################### # This is a comment diff --git a/bec_widgets/widgets/editors/monaco/scan_control_dialog.py b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py new file mode 100644 index 000000000..2cbb7121d --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py @@ -0,0 +1,145 @@ +""" +Scan Control Dialog for Monaco Editor + +This module provides a dialog wrapper around the ScanControl widget, +allowing users to configure and generate scan code that can be inserted +into the Monaco editor. +""" + +from bec_lib.device import Device +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QPushButton, QVBoxLayout + +from bec_widgets.widgets.control.scan_control import ScanControl + +logger = bec_logger.logger + + +class ScanControlDialog(QDialog): + """ + Dialog window containing the ScanControl widget for generating scan code. + + This dialog allows users to configure scan parameters and generates + Python code that can be inserted into the Monaco editor. + """ + + def __init__(self, parent=None, client=None): + super().__init__(parent) + self.setWindowTitle("Insert Scan") + + # Store the client for passing to ScanControl + self.client = client + self._scan_code = "" + + self._setup_ui() + + def sizeHint(self) -> QSize: + return QSize(600, 800) + + def _setup_ui(self): + """Setup the dialog UI with ScanControl widget and buttons.""" + layout = QVBoxLayout(self) + + # Create the scan control widget + self.scan_control = ScanControl(parent=self, client=self.client) + self.scan_control.show_scan_control_buttons(False) + layout.addWidget(self.scan_control) + + # Create dialog buttons + button_box = QDialogButtonBox(Qt.Orientation.Horizontal, self) + + # Create custom buttons with appropriate text + insert_button = QPushButton("Insert") + cancel_button = QPushButton("Cancel") + + button_box.addButton(insert_button, QDialogButtonBox.ButtonRole.AcceptRole) + button_box.addButton(cancel_button, QDialogButtonBox.ButtonRole.RejectRole) + + layout.addWidget(button_box) + + # Connect button signals + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + def _generate_scan_code(self): + """Generate Python code for the configured scan.""" + try: + # Get scan parameters from the scan control widget + args, kwargs = self.scan_control.get_scan_parameters() + scan_name = self.scan_control.current_scan + + if not scan_name: + self._scan_code = "" + return + + # Process arguments and add device prefix where needed + processed_args = self._process_arguments_for_code_generation(args) + processed_kwargs = self._process_kwargs_for_code_generation(kwargs) + + # Generate the Python code string + code_parts = [] + + # Process arguments and keyword arguments + all_args = [] + + # Add positional arguments + if processed_args: + all_args.extend(processed_args) + + # Add keyword arguments (excluding metadata) + if processed_kwargs: + kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items() if k != "metadata"] + all_args.extend(kwargs_strs) + + # Join all arguments and create the scan call + args_str = ", ".join(all_args) + if args_str: + code_parts.append(f"scans.{scan_name}({args_str})") + else: + code_parts.append(f"scans.{scan_name}()") + + self._scan_code = "\n".join(code_parts) + + except Exception as e: + logger.error(f"Error generating scan code: {e}") + self._scan_code = f"# Error generating scan code: {e}\n" + + def _process_arguments_for_code_generation(self, args): + """Process arguments to add device prefixes and proper formatting.""" + return [self._format_value_for_code(arg) for arg in args] + + def _process_kwargs_for_code_generation(self, kwargs): + """Process keyword arguments to add device prefixes and proper formatting.""" + return {key: self._format_value_for_code(value) for key, value in kwargs.items()} + + def _format_value_for_code(self, value): + """Format a single value for code generation.""" + if isinstance(value, Device): + return f"dev.{value.name}" + return repr(value) + + def get_scan_code(self) -> str: + """ + Get the generated scan code. + + Returns: + str: The Python code for the configured scan. + """ + return self._scan_code + + def accept(self): + """Override accept to generate code before closing.""" + self._generate_scan_code() + super().accept() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = ScanControlDialog() + dialog.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index 38a5b2744..ba17e36bc 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -1,13 +1,18 @@ import datetime import importlib +import importlib.metadata import os +import re +from bec_qthemes import material_icon +from qtpy.QtCore import Signal from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection from bec_widgets.widgets.containers.explorer.explorer import Explorer +from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget @@ -17,16 +22,19 @@ class IDEExplorer(BECWidget, QWidget): PLUGIN = True RPC = False + file_open_requested = Signal(str, str) + file_preview_requested = Signal(str, str) + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self._sections = set() + self._sections = [] # Use list to maintain order instead of set self.main_explorer = Explorer(parent=self) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.main_explorer) self.setLayout(layout) - self.sections = ["scripts"] + self.sections = ["scripts", "macros"] @SafeProperty(list) def sections(self): @@ -35,10 +43,16 @@ def sections(self): @sections.setter def sections(self, value): existing_sections = set(self._sections) - self._sections = set(value) - self._update_section_visibility(self._sections - existing_sections) + new_sections = set(value) + # Find sections to add, maintaining the order from the input value list + sections_to_add = [ + section for section in value if section in (new_sections - existing_sections) + ] + self._sections = list(value) # Store as ordered list + self._update_section_visibility(sections_to_add) def _update_section_visibility(self, sections): + # sections is now an ordered list, not a set for section in sections: self._add_section(section) @@ -46,15 +60,18 @@ def _add_section(self, section_name): match section_name.lower(): case "scripts": self.add_script_section() + case "macros": + self.add_macro_section() case _: pass def add_script_section(self): section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0) - section.expanded = False script_explorer = Explorer(parent=self) script_widget = ScriptTreeWidget(parent=self) + script_widget.file_open_requested.connect(self._emit_file_open_scripts_local) + script_widget.file_selected.connect(self._emit_file_preview_scripts_local) local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) local_scripts_section.header_add_button.clicked.connect(self._add_local_script) local_scripts_section.set_widget(script_widget) @@ -77,15 +94,85 @@ def add_script_section(self): if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir): return - shared_script_section = CollapsibleSection(title="Shared", parent=self) + shared_script_section = CollapsibleSection(title="Shared (Read-only)", parent=self) + shared_script_section.setToolTip("Shared scripts (read-only)") shared_script_widget = ScriptTreeWidget(parent=self) shared_script_section.set_widget(shared_script_widget) shared_script_widget.set_directory(plugin_scripts_dir) script_explorer.add_section(shared_script_section) + shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared) + shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared) # macros_section = CollapsibleSection("MACROS", indentation=0) # macros_section.set_widget(QLabel("Macros will be implemented later")) # self.main_explorer.add_section(macros_section) + def add_macro_section(self): + section = CollapsibleSection( + parent=self, title="MACROS", indentation=0, show_add_button=True + ) + section.header_add_button.setIcon(material_icon("refresh", size=(20, 20))) + section.header_add_button.setToolTip("Reload all macros") + section.header_add_button.clicked.connect(self._reload_macros) + + macro_explorer = Explorer(parent=self) + macro_widget = MacroTreeWidget(parent=self) + macro_widget.macro_open_requested.connect(self._emit_file_open_macros_local) + macro_widget.macro_selected.connect(self._emit_file_preview_macros_local) + local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) + local_macros_section.header_add_button.clicked.connect(self._add_local_macro) + local_macros_section.set_widget(macro_widget) + local_macro_dir = self.client._service_config.model.user_macros.base_path + if not os.path.exists(local_macro_dir): + os.makedirs(local_macro_dir) + macro_widget.set_directory(local_macro_dir) + macro_explorer.add_section(local_macros_section) + + section.set_widget(macro_explorer) + self.main_explorer.add_section(section) + + plugin_macros_dir = None + plugins = importlib.metadata.entry_points(group="bec") + for plugin in plugins: + if plugin.name == "plugin_bec": + plugin = plugin.load() + plugin_macros_dir = os.path.join(plugin.__path__[0], "macros") + break + + if not plugin_macros_dir or not os.path.exists(plugin_macros_dir): + return + shared_macro_section = CollapsibleSection(title="Shared (Read-only)", parent=self) + shared_macro_section.setToolTip("Shared macros (read-only)") + shared_macro_widget = MacroTreeWidget(parent=self) + shared_macro_section.set_widget(shared_macro_widget) + shared_macro_widget.set_directory(plugin_macros_dir) + macro_explorer.add_section(shared_macro_section) + shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared) + shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared) + + def _emit_file_open_scripts_local(self, file_name: str): + self.file_open_requested.emit(file_name, "scripts/local") + + def _emit_file_preview_scripts_local(self, file_name: str): + self.file_preview_requested.emit(file_name, "scripts/local") + + def _emit_file_open_scripts_shared(self, file_name: str): + self.file_open_requested.emit(file_name, "scripts/shared") + + def _emit_file_preview_scripts_shared(self, file_name: str): + self.file_preview_requested.emit(file_name, "scripts/shared") + + def _emit_file_open_macros_local(self, function_name: str, file_path: str): + self.file_open_requested.emit(file_path, "macros/local") + + def _emit_file_preview_macros_local(self, function_name: str, file_path: str): + self.file_preview_requested.emit(file_path, "macros/local") + + def _emit_file_open_macros_shared(self, function_name: str, file_path: str): + self.file_open_requested.emit(file_path, "macros/shared") + + def _emit_file_preview_macros_shared(self, function_name: str, file_path: str): + self.file_preview_requested.emit(file_path, "macros/shared") + def _add_local_script(self): """Show a dialog to enter the name of a new script and create it.""" @@ -136,6 +223,98 @@ def _add_local_script(self): # Show error if file creation failed QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}") + def _add_local_macro(self): + """Show a dialog to enter the name of a new macro function and create it.""" + + target_section = self.main_explorer.get_section("MACROS") + macro_dir_section = target_section.content_widget.get_section("Local") + + local_macro_dir = macro_dir_section.content_widget.directory + + # Prompt user for function name + function_name, ok = QInputDialog.getText(self, "New Macro", f"Enter macro function name:") + + if not ok or not function_name: + return # User cancelled or didn't enter a name + + # Sanitize function name + function_name = re.sub(r"[^a-zA-Z0-9_]", "_", function_name) + if not function_name or function_name[0].isdigit(): + QMessageBox.warning( + self, "Invalid Name", "Function name must be a valid Python identifier." + ) + return + + # Create filename based on function name + filename = f"{function_name}.py" + file_path = os.path.join(local_macro_dir, filename) + + # Check if file already exists + if os.path.exists(file_path): + response = QMessageBox.question( + self, + "File exists", + f"The file '{filename}' already exists. Do you want to overwrite it?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if response != QMessageBox.StandardButton.Yes: + return # User chose not to overwrite + + try: + # Create the file with a macro function template + with open(file_path, "w", encoding="utf-8") as f: + f.write( + f'''""" +{function_name} macro - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +""" + + +def {function_name}(): + """ + Description of what this macro does. + + Add your macro implementation here. + """ + print(f"Executing macro: {function_name}") + # TODO: Add your macro code here + pass +''' + ) + + # Refresh the macro tree to show the new function + macro_dir_section.content_widget.refresh() + + except Exception as e: + # Show error if file creation failed + QMessageBox.critical(self, "Error", f"Failed to create macro: {str(e)}") + + def _reload_macros(self): + """Reload all macros using the BEC client.""" + try: + if hasattr(self.client, "macros"): + self.client.macros.load_all_user_macros() + + # Refresh the macro tree widgets to show updated functions + target_section = self.main_explorer.get_section("MACROS") + if target_section and hasattr(target_section, "content_widget"): + local_section = target_section.content_widget.get_section("Local") + if local_section and hasattr(local_section, "content_widget"): + local_section.content_widget.refresh() + + shared_section = target_section.content_widget.get_section("Shared") + if shared_section and hasattr(shared_section, "content_widget"): + shared_section.content_widget.refresh() + + QMessageBox.information( + self, "Reload Macros", "Macros have been reloaded successfully." + ) + else: + QMessageBox.warning(self, "Reload Macros", "Macros functionality is not available.") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}") + if __name__ == "__main__": from qtpy.QtWidgets import QApplication diff --git a/tests/unit_tests/test_tree_widget.py b/tests/unit_tests/test_tree_widget.py new file mode 100644 index 000000000..7a470cce5 --- /dev/null +++ b/tests/unit_tests/test_tree_widget.py @@ -0,0 +1,22 @@ +import pytest +from qtpy.QtWidgets import QTreeView, QWidget + +from bec_widgets.utils.colors import apply_theme + + +class DummyTree(QWidget): + def __init__(self): + super().__init__() + tree = QTreeView(self) + + +@pytest.fixture +def tree_widget(qtbot): + tree = DummyTree() + qtbot.addWidget(tree) + qtbot.waitExposed(tree) + yield tree + + +def test_tree_widget_init(tree_widget): + assert isinstance(tree_widget, QWidget) From 752d9ba57509c501f5bab804dacb0b392eeb9ceb Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Fri, 17 Oct 2025 16:09:06 +0200 Subject: [PATCH 107/161] refactor: move splitter weights to view; change developerview to viewbase --- bec_widgets/applications/main_app.py | 25 +- bec_widgets/applications/views/view.py | 103 ++++- .../examples/developer_view/developer_view.py | 430 ++---------------- .../developer_view/developer_widget.py | 346 ++++++++++++++ 4 files changed, 502 insertions(+), 402 deletions(-) create mode 100644 bec_widgets/examples/developer_view/developer_widget.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index da210c973..c1de6c4e5 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -7,6 +7,7 @@ DeviceManagerWidget, ) from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup +from bec_widgets.examples.developer_view.developer_view import DeveloperView 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 @@ -48,6 +49,7 @@ def _add_views(self): self.add_section("BEC Applications", "bec_apps") self.ads = AdvancedDockArea(self) self.device_manager = DeviceManagerWidget(self) + self.developer_view = DeveloperView(self) self.add_view( icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks" @@ -59,6 +61,13 @@ def _add_views(self): widget=self.device_manager, mini_text="DM", ) + self.add_view( + icon="code_blocks", + title="IDE", + widget=self.developer_view, + id="developer_view", + exclusive=True, + ) if self._show_examples: self.add_section("Examples", "examples") @@ -195,7 +204,21 @@ def _on_view_selected(self, vid: str) -> None: app = QApplication([sys.argv[0], *qt_args]) apply_theme("dark") w = BECMainApp(show_examples=args.examples) - w.resize(1920, 1200) + + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + w.resize(width, height) w.show() sys.exit(app.exec()) diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py index 3b98f7568..635f68b15 100644 --- a/bec_widgets/applications/views/view.py +++ b/bec_widgets/applications/views/view.py @@ -1,6 +1,8 @@ from __future__ import annotations -from qtpy.QtCore import QEventLoop +from typing import List + +from qtpy.QtCore import QEventLoop, Qt, QTimer from qtpy.QtWidgets import ( QDialog, QDialogButtonBox, @@ -9,6 +11,7 @@ QLabel, QMessageBox, QPushButton, + QSplitter, QStackedLayout, QVBoxLayout, QWidget, @@ -20,6 +23,42 @@ from bec_widgets.widgets.plots.waveform.waveform import Waveform +def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: + """ + Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. + Works for horizontal or vertical splitters and sets matching stretch factors. + """ + + def apply(): + n = splitter.count() + if n == 0: + return + w = list(weights[:n]) + [1] * max(0, n - len(weights)) + w = [max(0.0, float(x)) for x in w] + tot_w = sum(w) + if tot_w <= 0: + w = [1.0] * n + tot_w = float(n) + total_px = ( + splitter.width() + if splitter.orientation() == Qt.Orientation.Horizontal + else splitter.height() + ) + if total_px < 2: + QTimer.singleShot(0, apply) + return + sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] + diff = total_px - sum(sizes) + if diff != 0: + idx = max(range(n), key=lambda i: w[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, wi in enumerate(w): + splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) + + QTimer.singleShot(0, apply) + + class ViewBase(QWidget): """Wrapper for a content widget used inside the main app's stacked view. @@ -76,6 +115,68 @@ def on_exit(self) -> bool: """ return True + ####### Default view has to be done with setting up splitters ######## + def set_default_view(self, horizontal_weights: list, vertical_weights: list): + """Apply initial weights to every horizontal and vertical splitter. + + Examples: + horizontal_weights = [1, 3, 2, 1] + vertical_weights = [3, 7] # top:bottom = 30:70 + """ + splitters_h = [] + splitters_v = [] + for splitter in self.findChildren(QSplitter): + if splitter.orientation() == Qt.Orientation.Horizontal: + splitters_h.append(splitter) + elif splitter.orientation() == Qt.Orientation.Vertical: + splitters_v.append(splitter) + + def apply_all(): + for s in splitters_h: + set_splitter_weights(s, horizontal_weights) + for s in splitters_v: + set_splitter_weights(s, vertical_weights) + + QTimer.singleShot(0, apply_all) + + def set_stretch(self, *, horizontal=None, vertical=None): + """Update splitter weights and re-apply to all splitters. + + Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + #################################################################################################### # Example views for demonstration/testing purposes diff --git a/bec_widgets/examples/developer_view/developer_view.py b/bec_widgets/examples/developer_view/developer_view.py index 01f9dfbc4..a6040e3c9 100644 --- a/bec_widgets/examples/developer_view/developer_view.py +++ b/bec_widgets/examples/developer_view/developer_view.py @@ -1,413 +1,29 @@ -import re -from typing import List +from qtpy.QtWidgets import QWidget -import markdown -import PySide6QtAds as QtAds -from bec_lib.endpoints import MessageEndpoints -from bec_lib.script_executor import upload_script -from bec_qthemes import material_icon -from PySide6QtAds import CDockManager, CDockWidget -from qtpy.QtCore import Qt, QTimer -from qtpy.QtGui import QKeySequence, QShortcut -from qtpy.QtWidgets import QSplitter, QTextEdit, QVBoxLayout, QWidget +from bec_widgets.applications.views.view import ViewBase +from bec_widgets.examples.developer_view.developer_widget import DeveloperWidget -from bec_widgets import BECWidget -from bec_widgets.utils.error_popups import SafeSlot -from bec_widgets.utils.toolbars.actions import MaterialIconAction -from bec_widgets.utils.toolbars.bundles import ToolbarBundle -from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea -from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock -from bec_widgets.widgets.editors.web_console.web_console import WebConsole -from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer - -def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: - """ - Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. - Works for horizontal or vertical splitters and sets matching stretch factors. +class DeveloperView(ViewBase): """ - - def apply(): - n = splitter.count() - if n == 0: - return - w = list(weights[:n]) + [1] * max(0, n - len(weights)) - w = [max(0.0, float(x)) for x in w] - tot_w = sum(w) - if tot_w <= 0: - w = [1.0] * n - tot_w = float(n) - total_px = ( - splitter.width() if splitter.orientation() == Qt.Horizontal else splitter.height() - ) - if total_px < 2: - QTimer.singleShot(0, apply) - return - sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] - diff = total_px - sum(sizes) - if diff != 0: - idx = max(range(n), key=lambda i: w[i]) - sizes[idx] = max(1, sizes[idx] + diff) - splitter.setSizes(sizes) - for i, wi in enumerate(w): - splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) - - QTimer.singleShot(0, apply) - - -def markdown_to_html(md_text: str) -> str: - """Convert Markdown with syntax highlighting to HTML (Qt-compatible).""" - - # Preprocess: convert consecutive >>> lines to Python code blocks - def replace_python_examples(match): - indent = match.group(1) - examples = match.group(2) - # Remove >>> prefix and clean up the code - lines = [] - for line in examples.strip().split("\n"): - line = line.strip() - if line.startswith(">>> "): - lines.append(line[4:]) # Remove '>>> ' - elif line.startswith(">>>"): - lines.append(line[3:]) # Remove '>>>' - code = "\n".join(lines) - - return f"{indent}```python\n{indent}{code}\n{indent}```" - - # Match one or more consecutive >>> lines (with same indentation) - pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)" - md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE) - - extensions = ["fenced_code", "codehilite", "tables", "sane_lists"] - html = markdown.markdown( - md_text, - extensions=extensions, - extension_configs={ - "codehilite": {"linenums": False, "guess_lang": False, "noclasses": True} - }, - output_format="html", - ) - - # Remove hardcoded background colors that conflict with themes - html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html) - html = re.sub(r"background: #[^;]*;", "", html) - - # Add CSS to force code blocks to wrap - css = """ - + A view for users to write scripts and macros and execute them within the application. """ - return css + html - - -class DeveloperView(BECWidget, QWidget): - - def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) - - # Top-level layout hosting a toolbar and the dock manager - self._root_layout = QVBoxLayout(self) - self._root_layout.setContentsMargins(0, 0, 0, 0) - self._root_layout.setSpacing(0) - self.toolbar = ModularToolBar(self) - self.init_developer_toolbar() - self._root_layout.addWidget(self.toolbar) - - self.dock_manager = CDockManager(self) - self.dock_manager.setStyleSheet("") - self._root_layout.addWidget(self.dock_manager) - - # Initialize the widgets - self.explorer = IDEExplorer(self) - self.console = WebConsole(self) - self.terminal = WebConsole(self, startup_cmd="") - self.monaco = MonacoDock(self) - self.monaco.save_enabled.connect(self._on_save_enabled_update) - self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom") - self.signature_help = QTextEdit(self) - self.signature_help.setAcceptRichText(True) - self.signature_help.setReadOnly(True) - self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) - opt = self.signature_help.document().defaultTextOption() - opt.setWrapMode(opt.WrapMode.WrapAnywhere) - self.signature_help.document().setDefaultTextOption(opt) - self.monaco.signature_help.connect( - lambda text: self.signature_help.setHtml(markdown_to_html(text)) - ) - - # Create the dock widgets - self.explorer_dock = QtAds.CDockWidget("Explorer", self) - self.explorer_dock.setWidget(self.explorer) - - self.console_dock = QtAds.CDockWidget("Console", self) - self.console_dock.setWidget(self.console) - - self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self) - self.monaco_dock.setWidget(self.monaco) - - self.terminal_dock = QtAds.CDockWidget("Terminal", self) - self.terminal_dock.setWidget(self.terminal) - - # Monaco will be central widget - self.dock_manager.setCentralWidget(self.monaco_dock) - - # Add the dock widgets to the dock manager - area_bottom = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock - ) - self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom) - - area_left = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock - ) - area_left.titleBar().setVisible(False) - - for dock in self.dock_manager.dockWidgets(): - # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea - # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same - dock.setFeature(CDockWidget.DockWidgetClosable, False) - dock.setFeature(CDockWidget.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetMovable, False) - - self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self) - self.plotting_ads_dock.setWidget(self.plotting_ads) - - self.signature_dock = QtAds.CDockWidget("Signature Help", self) - self.signature_dock.setWidget(self.signature_help) - - area_right = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock - ) - self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right) + def __init__( + self, + parent: QWidget | None = None, + content: QWidget | None = None, + *, + id: str | None = None, + title: str | None = None, + ): + super().__init__(parent=parent, content=content, id=id, title=title) + self.developer_widget = DeveloperWidget(parent=self) + self.set_content(self.developer_widget) # Apply stretch after the layout is done self.set_default_view([2, 5, 3], [7, 3]) - # Connect editor signals - self.explorer.file_open_requested.connect(self._open_new_file) - - self.toolbar.show_bundles(["save", "execution", "settings"]) - - def init_developer_toolbar(self): - """Initialize the developer toolbar with necessary actions and widgets.""" - save_button = MaterialIconAction( - icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self - ) - save_button.action.triggered.connect(self.on_save) - self.toolbar.components.add_safe("save", save_button) - - save_as_button = MaterialIconAction( - icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self - ) - self.toolbar.components.add_safe("save_as", save_as_button) - - save_bundle = ToolbarBundle("save", self.toolbar.components) - save_bundle.add_action("save") - save_bundle.add_action("save_as") - self.toolbar.add_bundle(save_bundle) - - run_action = MaterialIconAction( - icon_name="play_arrow", - tooltip="Run current file", - label_text="Run", - filled=True, - parent=self, - ) - run_action.action.triggered.connect(self.on_execute) - self.toolbar.components.add_safe("run", run_action) - - stop_action = MaterialIconAction( - icon_name="stop", - tooltip="Stop current execution", - label_text="Stop", - filled=True, - parent=self, - ) - stop_action.action.triggered.connect(self.on_stop) - self.toolbar.components.add_safe("stop", stop_action) - - execution_bundle = ToolbarBundle("execution", self.toolbar.components) - execution_bundle.add_action("run") - execution_bundle.add_action("stop") - self.toolbar.add_bundle(execution_bundle) - - vim_action = MaterialIconAction( - icon_name="vim", - tooltip="Toggle Vim Mode", - label_text="Vim", - filled=True, - parent=self, - checkable=True, - ) - self.toolbar.components.add_safe("vim", vim_action) - vim_action.action.triggered.connect(self.on_vim_triggered) - - settings_bundle = ToolbarBundle("settings", self.toolbar.components) - settings_bundle.add_action("vim") - self.toolbar.add_bundle(settings_bundle) - - save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self) - save_shortcut.activated.connect(self.on_save) - save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self) - save_as_shortcut.activated.connect(self.on_save_as) - - ####### Default view has to be done with setting up splitters ######## - def set_default_view(self, horizontal_weights: list, vertical_weights: list): - """Apply initial weights to every horizontal and vertical splitter. - - Examples: - horizontal_weights = [1, 3, 2, 1] - vertical_weights = [3, 7] # top:bottom = 30:70 - """ - splitters_h = [] - splitters_v = [] - for splitter in self.findChildren(QSplitter): - if splitter.orientation() == Qt.Horizontal: - splitters_h.append(splitter) - elif splitter.orientation() == Qt.Vertical: - splitters_v.append(splitter) - - def apply_all(): - for s in splitters_h: - set_splitter_weights(s, horizontal_weights) - for s in splitters_v: - set_splitter_weights(s, vertical_weights) - - QTimer.singleShot(0, apply_all) - - def set_stretch(self, *, horizontal=None, vertical=None): - """Update splitter weights and re-apply to all splitters. - - Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict - for convenience: horizontal roles = {"left","center","right"}, - vertical roles = {"top","bottom"}. - """ - - def _coerce_h(x): - if x is None: - return None - if isinstance(x, (list, tuple)): - return list(map(float, x)) - if isinstance(x, dict): - return [ - float(x.get("left", 1)), - float(x.get("center", x.get("middle", 1))), - float(x.get("right", 1)), - ] - return None - - def _coerce_v(x): - if x is None: - return None - if isinstance(x, (list, tuple)): - return list(map(float, x)) - if isinstance(x, dict): - return [float(x.get("top", 1)), float(x.get("bottom", 1))] - return None - - h = _coerce_h(horizontal) - v = _coerce_v(vertical) - if h is None: - h = [1, 1, 1] - if v is None: - v = [1, 1] - self.set_default_view(h, v) - - def _open_new_file(self, file_name: str, scope: str): - self.monaco.open_file(file_name) - - # Set read-only mode for shared files - if "shared" in scope: - self.monaco.set_file_readonly(file_name, True) - - # Add appropriate icon based on file type - if "script" in scope: - # Use script icon for script files - icon = material_icon("script", size=(24, 24)) - self.monaco.set_file_icon(file_name, icon) - elif "macro" in scope: - # Use function icon for macro files - icon = material_icon("function", size=(24, 24)) - self.monaco.set_file_icon(file_name, icon) - - @SafeSlot() - def on_save(self): - self.monaco.save_file() - - @SafeSlot() - def on_save_as(self): - self.monaco.save_file(force_save_as=True) - - @SafeSlot() - def on_vim_triggered(self): - self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked()) - - @SafeSlot(bool) - def _on_save_enabled_update(self, enabled: bool): - self.toolbar.components.get_action("save").action.setEnabled(enabled) - self.toolbar.components.get_action("save_as").action.setEnabled(enabled) - - @SafeSlot() - def on_execute(self): - self.script_editor_tab = self.monaco.last_focused_editor - if not self.script_editor_tab: - return - self.current_script_id = upload_script( - self.client.connector, self.script_editor_tab.widget().get_text() - ) - self.console.write(f'bec._run_script("{self.current_script_id}")') - print(f"Uploaded script with ID: {self.current_script_id}") - - @SafeSlot() - def on_stop(self): - print("Stopping execution...") - - @property - def current_script_id(self): - return self._current_script_id - - @current_script_id.setter - def current_script_id(self, value): - if not isinstance(value, str): - raise ValueError("Script ID must be a string.") - self._current_script_id = value - self._update_subscription() - - def _update_subscription(self): - if self.current_script_id: - self.bec_dispatcher.connect_slot( - self.on_script_execution_info, - MessageEndpoints.script_execution_info(self.current_script_id), - ) - else: - self.bec_dispatcher.disconnect_slot( - self.on_script_execution_info, - MessageEndpoints.script_execution_info(self.current_script_id), - ) - - @SafeSlot(dict, dict) - def on_script_execution_info(self, content: dict, metadata: dict): - print(f"Script execution info: {content}") - current_lines = content.get("current_lines") - if not current_lines: - self.script_editor_tab.widget().clear_highlighted_lines() - return - line_number = current_lines[0] - self.script_editor_tab.widget().clear_highlighted_lines() - self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) - if __name__ == "__main__": import sys @@ -421,6 +37,20 @@ def on_script_execution_info(self, content: dict, metadata: dict): apply_theme("dark") _app = BECMainApp() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + _app.resize(width, height) developer_view = DeveloperView() _app.add_view( icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True diff --git a/bec_widgets/examples/developer_view/developer_widget.py b/bec_widgets/examples/developer_view/developer_widget.py new file mode 100644 index 000000000..d60096666 --- /dev/null +++ b/bec_widgets/examples/developer_view/developer_widget.py @@ -0,0 +1,346 @@ +import re + +import markdown +import PySide6QtAds as QtAds +from bec_lib.endpoints import MessageEndpoints +from bec_lib.script_executor import upload_script +from bec_qthemes import material_icon +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtGui import QKeySequence, QShortcut +from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget + +from bec_widgets import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock +from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + + +def markdown_to_html(md_text: str) -> str: + """Convert Markdown with syntax highlighting to HTML (Qt-compatible).""" + + # Preprocess: convert consecutive >>> lines to Python code blocks + def replace_python_examples(match): + indent = match.group(1) + examples = match.group(2) + # Remove >>> prefix and clean up the code + lines = [] + for line in examples.strip().split("\n"): + line = line.strip() + if line.startswith(">>> "): + lines.append(line[4:]) # Remove '>>> ' + elif line.startswith(">>>"): + lines.append(line[3:]) # Remove '>>>' + code = "\n".join(lines) + + return f"{indent}```python\n{indent}{code}\n{indent}```" + + # Match one or more consecutive >>> lines (with same indentation) + pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)" + md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE) + + extensions = ["fenced_code", "codehilite", "tables", "sane_lists"] + html = markdown.markdown( + md_text, + extensions=extensions, + extension_configs={ + "codehilite": {"linenums": False, "guess_lang": False, "noclasses": True} + }, + output_format="html", + ) + + # Remove hardcoded background colors that conflict with themes + html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html) + html = re.sub(r"background: #[^;]*;", "", html) + + # Add CSS to force code blocks to wrap + css = """ + + """ + + return css + html + + +class DeveloperWidget(BECWidget, QWidget): + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + self.toolbar = ModularToolBar(self) + self.init_developer_toolbar() + self._root_layout.addWidget(self.toolbar) + + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self._root_layout.addWidget(self.dock_manager) + + # Initialize the widgets + self.explorer = IDEExplorer(self) + self.console = WebConsole(self) + self.terminal = WebConsole(self, startup_cmd="") + self.monaco = MonacoDock(self) + self.monaco.save_enabled.connect(self._on_save_enabled_update) + self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom") + self.signature_help = QTextEdit(self) + self.signature_help.setAcceptRichText(True) + self.signature_help.setReadOnly(True) + self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + opt = self.signature_help.document().defaultTextOption() + opt.setWrapMode(opt.WrapMode.WrapAnywhere) + self.signature_help.document().setDefaultTextOption(opt) + self.monaco.signature_help.connect( + lambda text: self.signature_help.setHtml(markdown_to_html(text)) + ) + + # Create the dock widgets + self.explorer_dock = QtAds.CDockWidget("Explorer", self) + self.explorer_dock.setWidget(self.explorer) + + self.console_dock = QtAds.CDockWidget("Console", self) + self.console_dock.setWidget(self.console) + + self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self) + self.monaco_dock.setWidget(self.monaco) + + self.terminal_dock = QtAds.CDockWidget("Terminal", self) + self.terminal_dock.setWidget(self.terminal) + + # Monaco will be central widget + self.dock_manager.setCentralWidget(self.monaco_dock) + + # Add the dock widgets to the dock manager + area_bottom = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock + ) + self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom) + + area_left = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock + ) + area_left.titleBar().setVisible(False) + + for dock in self.dock_manager.dockWidgets(): + # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea + # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same + dock.setFeature(CDockWidget.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetMovable, False) + + self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self) + self.plotting_ads_dock.setWidget(self.plotting_ads) + + self.signature_dock = QtAds.CDockWidget("Signature Help", self) + self.signature_dock.setWidget(self.signature_help) + + area_right = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock + ) + self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right) + + # Connect editor signals + self.explorer.file_open_requested.connect(self._open_new_file) + + self.toolbar.show_bundles(["save", "execution", "settings"]) + + def init_developer_toolbar(self): + """Initialize the developer toolbar with necessary actions and widgets.""" + save_button = MaterialIconAction( + icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self + ) + save_button.action.triggered.connect(self.on_save) + self.toolbar.components.add_safe("save", save_button) + + save_as_button = MaterialIconAction( + icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self + ) + self.toolbar.components.add_safe("save_as", save_as_button) + + save_bundle = ToolbarBundle("save", self.toolbar.components) + save_bundle.add_action("save") + save_bundle.add_action("save_as") + self.toolbar.add_bundle(save_bundle) + + run_action = MaterialIconAction( + icon_name="play_arrow", + tooltip="Run current file", + label_text="Run", + filled=True, + parent=self, + ) + run_action.action.triggered.connect(self.on_execute) + self.toolbar.components.add_safe("run", run_action) + + stop_action = MaterialIconAction( + icon_name="stop", + tooltip="Stop current execution", + label_text="Stop", + filled=True, + parent=self, + ) + stop_action.action.triggered.connect(self.on_stop) + self.toolbar.components.add_safe("stop", stop_action) + + execution_bundle = ToolbarBundle("execution", self.toolbar.components) + execution_bundle.add_action("run") + execution_bundle.add_action("stop") + self.toolbar.add_bundle(execution_bundle) + + vim_action = MaterialIconAction( + icon_name="vim", + tooltip="Toggle Vim Mode", + label_text="Vim", + filled=True, + parent=self, + checkable=True, + ) + self.toolbar.components.add_safe("vim", vim_action) + vim_action.action.triggered.connect(self.on_vim_triggered) + + settings_bundle = ToolbarBundle("settings", self.toolbar.components) + settings_bundle.add_action("vim") + self.toolbar.add_bundle(settings_bundle) + + save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self) + save_shortcut.activated.connect(self.on_save) + save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self) + save_as_shortcut.activated.connect(self.on_save_as) + + def _open_new_file(self, file_name: str, scope: str): + self.monaco.open_file(file_name) + + # Set read-only mode for shared files + if "shared" in scope: + self.monaco.set_file_readonly(file_name, True) + + # Add appropriate icon based on file type + if "script" in scope: + # Use script icon for script files + icon = material_icon("script", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + elif "macro" in scope: + # Use function icon for macro files + icon = material_icon("function", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + + @SafeSlot() + def on_save(self): + self.monaco.save_file() + + @SafeSlot() + def on_save_as(self): + self.monaco.save_file(force_save_as=True) + + @SafeSlot() + def on_vim_triggered(self): + self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked()) + + @SafeSlot(bool) + def _on_save_enabled_update(self, enabled: bool): + self.toolbar.components.get_action("save").action.setEnabled(enabled) + self.toolbar.components.get_action("save_as").action.setEnabled(enabled) + + @SafeSlot() + def on_execute(self): + self.script_editor_tab = self.monaco.last_focused_editor + if not self.script_editor_tab: + return + self.current_script_id = upload_script( + self.client.connector, self.script_editor_tab.widget().get_text() + ) + self.console.write(f'bec._run_script("{self.current_script_id}")') + print(f"Uploaded script with ID: {self.current_script_id}") + + @SafeSlot() + def on_stop(self): + print("Stopping execution...") + + @property + def current_script_id(self): + return self._current_script_id + + @current_script_id.setter + def current_script_id(self, value): + if not isinstance(value, str): + raise ValueError("Script ID must be a string.") + self._current_script_id = value + self._update_subscription() + + def _update_subscription(self): + if self.current_script_id: + self.bec_dispatcher.connect_slot( + self.on_script_execution_info, + MessageEndpoints.script_execution_info(self.current_script_id), + ) + else: + self.bec_dispatcher.disconnect_slot( + self.on_script_execution_info, + MessageEndpoints.script_execution_info(self.current_script_id), + ) + + @SafeSlot(dict, dict) + def on_script_execution_info(self, content: dict, metadata: dict): + print(f"Script execution info: {content}") + current_lines = content.get("current_lines") + if not current_lines: + self.script_editor_tab.widget().clear_highlighted_lines() + return + line_number = current_lines[0] + self.script_editor_tab.widget().clear_highlighted_lines() + self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + from qtpy.QtWidgets import QApplication + + from bec_widgets.applications.main_app import BECMainApp + + app = QApplication(sys.argv) + apply_theme("dark") + + _app = BECMainApp() + screen = app.primaryScreen() + screen_geometry = screen.availableGeometry() + screen_width = screen_geometry.width() + screen_height = screen_geometry.height() + # 70% of screen height, keep 16:9 ratio + height = int(screen_height * 0.9) + width = int(height * (16 / 9)) + + # If width exceeds screen width, scale down + if width > screen_width * 0.9: + width = int(screen_width * 0.9) + height = int(width / (16 / 9)) + + _app.resize(width, height) + developer_view = DeveloperView() + _app.add_view( + icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True + ) + _app.show() + # developer_view.show() + # developer_view.setWindowTitle("Developer View") + # developer_view.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) From 7cd4f448b56d01e3fc65d7cdd86cfe326f57a613 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 8 Oct 2025 17:59:19 +0200 Subject: [PATCH 108/161] fix(view): apply view_id and view_title attributes to existing ViewBase instances --- bec_widgets/applications/main_app.py | 2 + .../developer_view/developer_widget.py | 3 +- .../explorer/collapsible_tree_section.py | 11 +- .../containers/explorer/macro_tree_widget.py | 115 +++++++++++++----- .../widgets/editors/monaco/monaco_tab.py | 63 +++++++++- .../widgets/editors/monaco/monaco_widget.py | 1 + .../utility/ide_explorer/ide_explorer.py | 42 ++++++- 7 files changed, 204 insertions(+), 33 deletions(-) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index c1de6c4e5..511f82a26 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -151,6 +151,8 @@ def add_view( # Wrap plain widgets into a ViewBase so enter/exit hooks are available if isinstance(widget, ViewBase): view_widget = widget + view_widget.view_id = id + view_widget.view_title = title else: view_widget = ViewBase(content=widget, parent=self, id=id, title=title) diff --git a/bec_widgets/examples/developer_view/developer_widget.py b/bec_widgets/examples/developer_view/developer_widget.py index d60096666..25061174f 100644 --- a/bec_widgets/examples/developer_view/developer_widget.py +++ b/bec_widgets/examples/developer_view/developer_widget.py @@ -158,6 +158,7 @@ def __init__(self, parent=None, **kwargs): # Connect editor signals self.explorer.file_open_requested.connect(self._open_new_file) + self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) self.toolbar.show_bundles(["save", "execution", "settings"]) @@ -225,7 +226,7 @@ def init_developer_toolbar(self): save_as_shortcut.activated.connect(self.on_save_as) def _open_new_file(self, file_name: str, scope: str): - self.monaco.open_file(file_name) + self.monaco.open_file(file_name, scope) # Set read-only mode for shared files if "shared" in scope: diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index ad0b9ae1c..95975b031 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -24,7 +24,14 @@ class CollapsibleSection(QWidget): section_reorder_requested = Signal(str, str) # (source_title, target_title) - def __init__(self, parent=None, title="", indentation=10, show_add_button=False): + def __init__( + self, + parent=None, + title="", + indentation=10, + show_add_button=False, + tooltip: str | None = None, + ): super().__init__(parent=parent) self.title = title self.content_widget = None @@ -50,6 +57,8 @@ def __init__(self, parent=None, title="", indentation=10, show_add_button=False) self.header_button.mouseMoveEvent = self._header_mouse_move_event self.header_button.dragEnterEvent = self._header_drag_enter_event self.header_button.dropEvent = self._header_drop_event + if tooltip: + self.header_button.setToolTip(tooltip) self.drag_start_position = None diff --git a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py index 3b247d97f..be0885088 100644 --- a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py @@ -266,6 +266,45 @@ def set_directory(self, directory): self._scan_macro_functions() + def _create_file_item(self, py_file: Path) -> QStandardItem | None: + """Create a file item with its functions + + Args: + py_file: Path to the Python file + + Returns: + QStandardItem representing the file, or None if no functions found + """ + # Skip files starting with underscore + if py_file.name.startswith("_"): + return None + + try: + functions = self._extract_functions_from_file(py_file) + if not functions: + return None + + # Create a file node + file_item = QStandardItem(py_file.stem) + file_item.setData({"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole) + + # Add function nodes + for func_name, func_info in functions.items(): + func_item = QStandardItem(func_name) + func_data = { + "function_name": func_name, + "file_path": str(py_file), + "line_number": func_info.get("line_number", 1), + "type": "function", + } + func_item.setData(func_data, Qt.ItemDataRole.UserRole) + file_item.appendRow(func_item) + + return file_item + except Exception as e: + logger.warning(f"Failed to parse {py_file}: {e}") + return None + def _scan_macro_functions(self): """Scan the directory for Python files and extract macro functions""" self.model.clear() @@ -278,34 +317,9 @@ def _scan_macro_functions(self): python_files = list(Path(self.directory).glob("*.py")) for py_file in python_files: - # Skip files starting with underscore - if py_file.name.startswith("_"): - continue - - try: - functions = self._extract_functions_from_file(py_file) - if functions: - # Create a file node - file_item = QStandardItem(py_file.stem) - file_item.setData( - {"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole - ) - - # Add function nodes - for func_name, func_info in functions.items(): - func_item = QStandardItem(func_name) - func_data = { - "function_name": func_name, - "file_path": str(py_file), - "line_number": func_info.get("line_number", 1), - "type": "function", - } - func_item.setData(func_data, Qt.ItemDataRole.UserRole) - file_item.appendRow(func_item) - - self.model.appendRow(file_item) - except Exception as e: - logger.warning(f"Failed to parse {py_file}: {e}") + file_item = self._create_file_item(py_file) + if file_item: + self.model.appendRow(file_item) self.tree.expandAll() @@ -399,6 +413,51 @@ def refresh(self): return self._scan_macro_functions() + def refresh_file_item(self, file_path: str): + """Refresh a single file item by re-scanning its functions + + Args: + file_path: Path to the Python file to refresh + """ + if not file_path or not os.path.exists(file_path): + logger.warning(f"Cannot refresh file item: {file_path} does not exist") + return + + py_file = Path(file_path) + + # Find existing file item in the model + existing_item = None + existing_row = -1 + for row in range(self.model.rowCount()): + item = self.model.item(row) + if not item or not item.data(Qt.ItemDataRole.UserRole): + continue + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data.get("type") == "file" and item_data.get("file_path") == str(py_file): + existing_item = item + existing_row = row + break + + # Store expansion state if item exists + was_expanded = existing_item and self.tree.isExpanded(existing_item.index()) + + # Remove existing item if found + if existing_item and existing_row >= 0: + self.model.removeRow(existing_row) + + # Create new item using the helper method + new_item = self._create_file_item(py_file) + if new_item: + # Insert at the same position or append if it was a new file + insert_row = existing_row if existing_row >= 0 else self.model.rowCount() + self.model.insertRow(insert_row, new_item) + + # Restore expansion state + if was_expanded: + self.tree.expand(new_item.index()) + else: + self.tree.expand(new_item.index()) + def expand_all(self): """Expand all items in the tree""" self.tree.expandAll() diff --git a/bec_widgets/widgets/editors/monaco/monaco_tab.py b/bec_widgets/widgets/editors/monaco/monaco_tab.py index d4b62e78c..a73325578 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_tab.py +++ b/bec_widgets/widgets/editors/monaco/monaco_tab.py @@ -2,10 +2,11 @@ import os import pathlib -from typing import Any, cast +from typing import Any, Literal, cast import PySide6QtAds as QtAds from bec_lib.logger import bec_logger +from bec_lib.macro_update_handler import has_executable_code from PySide6QtAds import CDockWidget from qtpy.QtCore import QEvent, QTimer, Signal from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget @@ -25,6 +26,7 @@ class MonacoDock(BECWidget, QWidget): focused_editor = Signal(object) # Emitted when the focused editor changes save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled signature_help = Signal(str) # Emitted when signature help is requested + macro_file_updated = Signal(str) # Emitted when a macro file is saved def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) @@ -234,7 +236,7 @@ def add_editor( QTimer.singleShot(0, self._scan_and_fix_areas) return new_dock - def open_file(self, file_name: str): + def open_file(self, file_name: str, scope: str | None = None) -> None: """ Open a file in the specified area. If the file is already open, activate it. """ @@ -260,12 +262,14 @@ def open_file(self, file_name: str): editor_dock.setWindowTitle(file) editor_dock.setTabToolTip(file_name) editor_widget.open_file(file_name) + editor_widget.metadata["scope"] = scope return # File is not open, create a new editor editor_dock = self.add_editor(title=file, tooltip=file_name) widget = cast(MonacoWidget, editor_dock.widget()) widget.open_file(file_name) + widget.metadata["scope"] = scope def save_file( self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True @@ -281,11 +285,22 @@ def save_file( widget = self.last_focused_editor.widget() if self.last_focused_editor else None if not widget: return + if "macros" in widget.metadata.get("scope", ""): + if not self._validate_macros(widget.get_text()): + return + if widget.current_file and not force_save_as: if format_on_save and pathlib.Path(widget.current_file).suffix == ".py": widget.format() + with open(widget.current_file, "w", encoding="utf-8") as f: f.write(widget.get_text()) + + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(widget.current_file) + # pylint: disable=protected-access widget._original_content = widget.get_text() widget.save_enabled.emit(False) @@ -317,9 +332,53 @@ def save_file( dock.setWindowTitle(file.name) dock.setTabToolTip(str(file)) break + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(str(file)) print(f"Save file called, last focused editor: {self.last_focused_editor}") + def _validate_macros(self, source: str) -> bool: + # pylint: disable=protected-access + # Ensure the macro does not contain executable code before saving + exec_code, line_number = has_executable_code(source) + if exec_code: + if line_number is None: + msg = "The macro contains executable code. Please remove it before saving." + else: + msg = f"The macro contains executable code on line {line_number}. Please remove it before saving." + QMessageBox.warning(self, "Save Error", msg) + return False + return True + + def _update_macros(self, widget: MonacoWidget): + # pylint: disable=protected-access + if not widget.current_file: + return + # Check which macros have changed and broadcast the change + macros = self.client.macros._update_handler.get_macros_from_file(widget.current_file) + existing_macros = self.client.macros._update_handler.get_existing_macros( + widget.current_file + ) + + removed_macros = set(existing_macros.keys()) - set(macros.keys()) + added_macros = set(macros.keys()) - set(existing_macros.keys()) + for name, info in macros.items(): + if name in added_macros: + self.client.macros._update_handler.broadcast( + action="add", name=name, file_path=widget.current_file + ) + if ( + name in existing_macros + and info.get("source", "") != existing_macros[name]["source"] + ): + self.client.macros._update_handler.broadcast( + action="reload", name=name, file_path=widget.current_file + ) + for name in removed_macros: + self.client.macros._update_handler.broadcast(action="remove", name=name) + def set_vim_mode(self, enabled: bool): """ Set Vim mode for all editor widgets. diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index 1a28eec0e..dbfc9d5ab 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -63,6 +63,7 @@ def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs) self.editor.context_menu_action_triggered.connect(self._handle_context_menu_action) self._current_file = None self._original_content = "" + self.metadata = {} @property def current_file(self): diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index ba17e36bc..bec91e447 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -108,7 +108,11 @@ def add_script_section(self): def add_macro_section(self): section = CollapsibleSection( - parent=self, title="MACROS", indentation=0, show_add_button=True + parent=self, + title="MACROS", + indentation=0, + show_add_button=True, + tooltip="Macros are reusable functions that can be called from scripts or the console.", ) section.header_add_button.setIcon(material_icon("refresh", size=(20, 20))) section.header_add_button.setToolTip("Reload all macros") @@ -315,6 +319,42 @@ def _reload_macros(self): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}") + def refresh_macro_file(self, file_path: str): + """Refresh a single macro file in the tree widget. + + Args: + file_path: Path to the macro file that was updated + """ + target_section = self.main_explorer.get_section("MACROS") + if not target_section or not hasattr(target_section, "content_widget"): + return + + # Determine if this is a local or shared macro based on the file path + local_section = target_section.content_widget.get_section("Local") + shared_section = target_section.content_widget.get_section("Shared") + + # Check if file belongs to local macros directory + if ( + local_section + and hasattr(local_section, "content_widget") + and hasattr(local_section.content_widget, "directory") + ): + local_macro_dir = local_section.content_widget.directory + if local_macro_dir and file_path.startswith(local_macro_dir): + local_section.content_widget.refresh_file_item(file_path) + return + + # Check if file belongs to shared macros directory + if ( + shared_section + and hasattr(shared_section, "content_widget") + and hasattr(shared_section.content_widget, "directory") + ): + shared_macro_dir = shared_section.content_widget.directory + if shared_macro_dir and file_path.startswith(shared_macro_dir): + shared_section.content_widget.refresh_file_item(file_path) + return + if __name__ == "__main__": from qtpy.QtWidgets import QApplication From da1a3ddfc470898145c813291c0abcbe20116bcb Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Tue, 14 Oct 2025 09:31:07 +0200 Subject: [PATCH 109/161] fix(dependencies): add copier and typer to project dependencies --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e97d09547..1bb448e93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ dependencies = [ "qtmonaco~=0.7", "darkdetect~=0.8", "PySide6-QtAds==4.4.0", + "pylsp-bec", + "copier~=9.7", + "typer~=0.15", ] @@ -44,7 +47,6 @@ dev = [ "pytest-cov~=6.1.1", "watchdog~=6.0", "pre_commit~=4.2", - ] [project.urls] From 0d0eb1d8ee036c8d684449dc74c2d0f01829096d Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Wed, 8 Oct 2025 17:42:41 +0200 Subject: [PATCH 110/161] fix: developer view improvements and dependency updates --- bec_widgets/cli/client.py | 73 ++- .../developer_view/developer_widget.py | 74 +-- .../containers/explorer/explorer_delegate.py | 125 ++++ .../containers/explorer/macro_tree_widget.py | 99 +--- .../containers/explorer/script_tree_widget.py | 138 +---- .../monaco/{monaco_tab.py => monaco_dock.py} | 102 ++-- .../widgets/editors/monaco/monaco_widget.py | 41 +- .../utility/ide_explorer/ide_explorer.py | 46 +- pyproject.toml | 5 +- .../test_collapsible_tree_section.py | 119 ++++ tests/unit_tests/test_developer_view.py | 378 ++++++++++++ tests/unit_tests/test_ide_explorer.py | 422 ++++++++++++++ tests/unit_tests/test_macro_tree_widget.py | 548 ++++++++++++++++++ tests/unit_tests/test_monaco_dock.py | 425 ++++++++++++++ tests/unit_tests/test_monaco_editor.py | 85 ++- tests/unit_tests/test_tree_widget.py | 22 - 16 files changed, 2337 insertions(+), 365 deletions(-) create mode 100644 bec_widgets/widgets/containers/explorer/explorer_delegate.py rename bec_widgets/widgets/editors/monaco/{monaco_tab.py => monaco_dock.py} (85%) create mode 100644 tests/unit_tests/test_collapsible_tree_section.py create mode 100644 tests/unit_tests/test_developer_view.py create mode 100644 tests/unit_tests/test_macro_tree_widget.py create mode 100644 tests/unit_tests/test_monaco_dock.py delete mode 100644 tests/unit_tests/test_tree_widget.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 63e835ba9..dda269f0a 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -2686,26 +2686,54 @@ def set_html_text(self, text: str) -> None: class Minesweeper(RPCBase): ... +class MonacoDock(RPCBase): + """MonacoDock is a dock widget that contains Monaco editor instances.""" + + @rpc_call + 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 MonacoWidget(RPCBase): """A simple Monaco editor widget""" @rpc_call - def set_text(self, text: str) -> None: + def set_text( + self, text: "str", file_name: "str | None" = None, reset: "bool" = False + ) -> "None": """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. + file_name (str): Set the file name + reset (bool): If True, reset the original content to the new text. """ @rpc_call - def get_text(self) -> str: + def get_text(self) -> "str": """ Get the current text from the Monaco editor. """ @rpc_call - def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None: + def insert_text( + self, text: "str", line: "int | None" = None, column: "int | None" = None + ) -> "None": """ Insert text at the current cursor position or at a specified line and column. @@ -2716,7 +2744,7 @@ def insert_text(self, text: str, line: int | None = None, column: int | None = N """ @rpc_call - def delete_line(self, line: int | None = None) -> None: + def delete_line(self, line: "int | None" = None) -> "None": """ Delete a line in the Monaco editor. @@ -2725,7 +2753,16 @@ def delete_line(self, line: int | None = None) -> None: """ @rpc_call - def set_language(self, language: str) -> None: + def open_file(self, file_name: "str") -> "None": + """ + Open a file in the editor. + + Args: + file_name (str): The path + file name of the file that needs to be displayed. + """ + + @rpc_call + def set_language(self, language: "str") -> "None": """ Set the programming language for syntax highlighting in the Monaco editor. @@ -2734,13 +2771,13 @@ def set_language(self, language: str) -> None: """ @rpc_call - def get_language(self) -> str: + def get_language(self) -> "str": """ Get the current programming language set in the Monaco editor. """ @rpc_call - def set_theme(self, theme: str) -> None: + def set_theme(self, theme: "str") -> "None": """ Set the theme for the Monaco editor. @@ -2749,13 +2786,13 @@ def set_theme(self, theme: str) -> None: """ @rpc_call - def get_theme(self) -> str: + def get_theme(self) -> "str": """ Get the current theme of the Monaco editor. """ @rpc_call - def set_readonly(self, read_only: bool) -> None: + def set_readonly(self, read_only: "bool") -> "None": """ Set the Monaco editor to read-only mode. @@ -2766,10 +2803,10 @@ def set_readonly(self, read_only: bool) -> None: @rpc_call def set_cursor( self, - line: int, - column: int = 1, - move_to_position: Literal[None, "center", "top", "position"] = None, - ) -> None: + line: "int", + column: "int" = 1, + move_to_position: "Literal[None, 'center', 'top', 'position']" = None, + ) -> "None": """ Set the cursor position in the Monaco editor. @@ -2780,7 +2817,7 @@ def set_cursor( """ @rpc_call - def current_cursor(self) -> dict[str, int]: + def current_cursor(self) -> "dict[str, int]": """ Get the current cursor position in the Monaco editor. @@ -2789,7 +2826,7 @@ def current_cursor(self) -> dict[str, int]: """ @rpc_call - def set_minimap_enabled(self, enabled: bool) -> None: + def set_minimap_enabled(self, enabled: "bool") -> "None": """ Enable or disable the minimap in the Monaco editor. @@ -2798,7 +2835,7 @@ def set_minimap_enabled(self, enabled: bool) -> None: """ @rpc_call - def set_vim_mode_enabled(self, enabled: bool) -> None: + def set_vim_mode_enabled(self, enabled: "bool") -> "None": """ Enable or disable Vim mode in the Monaco editor. @@ -2807,7 +2844,7 @@ def set_vim_mode_enabled(self, enabled: bool) -> None: """ @rpc_call - def set_lsp_header(self, header: str) -> None: + def set_lsp_header(self, header: "str") -> "None": """ Set the LSP (Language Server Protocol) header for the Monaco editor. The header is used to provide context for language servers but is not displayed in the editor. @@ -2817,7 +2854,7 @@ def set_lsp_header(self, header: str) -> None: """ @rpc_call - def get_lsp_header(self) -> str: + def get_lsp_header(self) -> "str": """ Get the current LSP header set in the Monaco editor. diff --git a/bec_widgets/examples/developer_view/developer_widget.py b/bec_widgets/examples/developer_view/developer_widget.py index 25061174f..9e16bceb7 100644 --- a/bec_widgets/examples/developer_view/developer_widget.py +++ b/bec_widgets/examples/developer_view/developer_widget.py @@ -1,21 +1,22 @@ import re import markdown -import PySide6QtAds as QtAds from bec_lib.endpoints import MessageEndpoints from bec_lib.script_executor import upload_script from bec_qthemes import material_icon -from PySide6QtAds import CDockManager, CDockWidget from qtpy.QtGui import QKeySequence, QShortcut from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget +from shiboken6 import isValid +import bec_widgets.widgets.containers.ads as QtAds from bec_widgets import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.containers.ads import CDockManager, CDockWidget from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea -from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock from bec_widgets.widgets.editors.web_console.web_console import WebConsole from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -110,6 +111,7 @@ def __init__(self, parent=None, **kwargs): self.monaco.signature_help.connect( lambda text: self.signature_help.setHtml(markdown_to_html(text)) ) + self._current_script_id: str | None = None # Create the dock widgets self.explorer_dock = QtAds.CDockWidget("Explorer", self) @@ -141,9 +143,9 @@ def __init__(self, parent=None, **kwargs): for dock in self.dock_manager.dockWidgets(): # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same - dock.setFeature(CDockWidget.DockWidgetClosable, False) - dock.setFeature(CDockWidget.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetMovable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, False) self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self) self.plotting_ads_dock.setWidget(self.plotting_ads) @@ -174,6 +176,7 @@ def init_developer_toolbar(self): icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self ) self.toolbar.components.add_safe("save_as", save_as_button) + save_as_button.action.triggered.connect(self.on_save_as) save_bundle = ToolbarBundle("save", self.toolbar.components) save_bundle.add_action("save") @@ -272,29 +275,30 @@ def on_execute(self): @SafeSlot() def on_stop(self): - print("Stopping execution...") + if not self.current_script_id: + return + self.console.send_ctrl_c() @property def current_script_id(self): return self._current_script_id @current_script_id.setter - def current_script_id(self, value): - if not isinstance(value, str): + def current_script_id(self, value: str | None): + if value is not None and not isinstance(value, str): raise ValueError("Script ID must be a string.") + old_script_id = self._current_script_id self._current_script_id = value - self._update_subscription() + self._update_subscription(value, old_script_id) - def _update_subscription(self): - if self.current_script_id: - self.bec_dispatcher.connect_slot( - self.on_script_execution_info, - MessageEndpoints.script_execution_info(self.current_script_id), - ) - else: + def _update_subscription(self, new_script_id: str | None, old_script_id: str | None): + if old_script_id is not None: self.bec_dispatcher.disconnect_slot( - self.on_script_execution_info, - MessageEndpoints.script_execution_info(self.current_script_id), + self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id) + ) + if new_script_id is not None: + self.bec_dispatcher.connect_slot( + self.on_script_execution_info, MessageEndpoints.script_execution_info(new_script_id) ) @SafeSlot(dict, dict) @@ -308,6 +312,20 @@ def on_script_execution_info(self, content: dict, metadata: dict): self.script_editor_tab.widget().clear_highlighted_lines() self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) + def cleanup(self): + for dock in self.dock_manager.dockWidgets(): + self._delete_dock(dock) + return super().cleanup() + + 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() + if __name__ == "__main__": import sys @@ -321,24 +339,6 @@ def on_script_execution_info(self, content: dict, metadata: dict): apply_theme("dark") _app = BECMainApp() - screen = app.primaryScreen() - screen_geometry = screen.availableGeometry() - screen_width = screen_geometry.width() - screen_height = screen_geometry.height() - # 70% of screen height, keep 16:9 ratio - height = int(screen_height * 0.9) - width = int(height * (16 / 9)) - - # If width exceeds screen width, scale down - if width > screen_width * 0.9: - width = int(screen_width * 0.9) - height = int(width / (16 / 9)) - - _app.resize(width, height) - developer_view = DeveloperView() - _app.add_view( - icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True - ) _app.show() # developer_view.show() # developer_view.setWindowTitle("Developer View") diff --git a/bec_widgets/widgets/containers/explorer/explorer_delegate.py b/bec_widgets/widgets/containers/explorer/explorer_delegate.py new file mode 100644 index 000000000..7a8b41cbb --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/explorer_delegate.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import Any + +from qtpy.QtCore import QModelIndex, QRect, QSortFilterProxyModel, Qt +from qtpy.QtGui import QPainter +from qtpy.QtWidgets import QAction, QStyledItemDelegate, QTreeView + +from bec_widgets.utils.colors import get_theme_palette + + +class ExplorerDelegate(QStyledItemDelegate): + """Custom delegate to show action buttons on hover for the explorer""" + + def __init__(self, parent=None): + super().__init__(parent) + self.hovered_index = QModelIndex() + self.button_rects: list[QRect] = [] + self.current_macro_info = {} + self.target_model = QSortFilterProxyModel + + def paint(self, painter, option, index): + """Paint the item with action buttons on hover""" + # Paint the default item + super().paint(painter, option, index) + + # Early return if not hovering over this item + if index != self.hovered_index: + return + + tree_view = self.parent() + if not isinstance(tree_view, QTreeView): + return + + proxy_model = tree_view.model() + if not isinstance(proxy_model, self.target_model): + return + + actions = self.get_actions_for_current_item(proxy_model, index) + if actions: + self._draw_action_buttons(painter, option, actions) + + def _draw_action_buttons(self, painter, option, actions: list[Any]): + """Draw action buttons on the right side""" + button_size = 18 + margin = 4 + spacing = 2 + + # Calculate total width needed for all buttons + total_width = len(actions) * button_size + (len(actions) - 1) * spacing + + # Clear previous button rects and create new ones + self.button_rects.clear() + + # Calculate starting position (right side of the item) + start_x = option.rect.right() - total_width - margin + current_x = start_x + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Get theme colors for better integration + palette = get_theme_palette() + button_bg = palette.button().color() + button_bg.setAlpha(150) # Semi-transparent + + for action in actions: + if not action.isVisible(): + continue + + # Calculate button position + button_rect = QRect( + current_x, + option.rect.top() + (option.rect.height() - button_size) // 2, + button_size, + button_size, + ) + self.button_rects.append(button_rect) + + # Draw button background + painter.setBrush(button_bg) + painter.setPen(palette.mid().color()) + painter.drawRoundedRect(button_rect, 3, 3) + + # Draw action icon + icon = action.icon() + if not icon.isNull(): + icon_rect = button_rect.adjusted(2, 2, -2, -2) + icon.paint(painter, icon_rect) + + # Move to next button position + current_x += button_size + spacing + + painter.restore() + + def get_actions_for_current_item(self, model, index) -> list[QAction] | None: + """Get actions for the current item based on its type""" + return None + + def editorEvent(self, event, model, option, index): + """Handle mouse events for action buttons""" + # Early return if not a left click + if not ( + event.type() == event.Type.MouseButtonPress + and event.button() == Qt.MouseButton.LeftButton + ): + return super().editorEvent(event, model, option, index) + + actions = self.get_actions_for_current_item(model, index) + if not actions: + return super().editorEvent(event, model, option, index) + + # Check which button was clicked + visible_actions = [action for action in actions if action.isVisible()] + for i, button_rect in enumerate(self.button_rects): + if button_rect.contains(event.pos()) and i < len(visible_actions): + # Trigger the action + visible_actions[i].trigger() + return True + + return super().editorEvent(event, model, option, index) + + def set_hovered_index(self, index): + """Set the currently hovered index""" + self.hovered_index = index diff --git a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py index be0885088..2546eb351 100644 --- a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py @@ -5,24 +5,25 @@ from bec_lib.logger import bec_logger from qtpy.QtCore import QModelIndex, QRect, Qt, Signal -from qtpy.QtGui import QPainter, QStandardItem, QStandardItemModel -from qtpy.QtWidgets import QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget +from qtpy.QtGui import QStandardItem, QStandardItemModel +from qtpy.QtWidgets import QAction, QTreeView, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate logger = bec_logger.logger -class MacroItemDelegate(QStyledItemDelegate): +class MacroItemDelegate(ExplorerDelegate): """Custom delegate to show action buttons on hover for macro functions""" def __init__(self, parent=None): super().__init__(parent) - self.hovered_index = QModelIndex() self.macro_actions: list[Any] = [] self.button_rects: list[QRect] = [] self.current_macro_info = {} + self.target_model = QStandardItemModel def add_macro_action(self, action: Any) -> None: """Add an action for macro functions""" @@ -32,15 +33,7 @@ def clear_actions(self) -> None: """Remove all actions""" self.macro_actions.clear() - def paint(self, painter, option, index): - """Paint the item with action buttons on hover""" - # Paint the default item - super().paint(painter, option, index) - - # Early return if not hovering over this item - if index != self.hovered_index: - return - + def get_actions_for_current_item(self, model, index) -> list[QAction] | None: # Only show actions for macro functions (not directories) item = index.model().itemFromIndex(index) if not item or not item.data(Qt.ItemDataRole.UserRole): @@ -51,85 +44,7 @@ def paint(self, painter, option, index): return self.current_macro_info = macro_info - - if self.macro_actions: - self._draw_action_buttons(painter, option, self.macro_actions) - - def _draw_action_buttons(self, painter, option, actions: list[Any]): - """Draw action buttons on the right side""" - button_size = 18 - margin = 4 - spacing = 2 - - # Calculate total width needed for all buttons - total_width = len(actions) * button_size + (len(actions) - 1) * spacing - - # Clear previous button rects and create new ones - self.button_rects.clear() - - # Calculate starting position (right side of the item) - start_x = option.rect.right() - total_width - margin - current_x = start_x - - painter.save() - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get theme colors for better integration - palette = get_theme_palette() - button_bg = palette.button().color() - button_bg.setAlpha(150) # Semi-transparent - - for action in actions: - if not action.isVisible(): - continue - - # Calculate button position - button_rect = QRect( - current_x, - option.rect.top() + (option.rect.height() - button_size) // 2, - button_size, - button_size, - ) - self.button_rects.append(button_rect) - - # Draw button background - painter.setBrush(button_bg) - painter.setPen(palette.mid().color()) - painter.drawRoundedRect(button_rect, 3, 3) - - # Draw action icon - icon = action.icon() - if not icon.isNull(): - icon_rect = button_rect.adjusted(2, 2, -2, -2) - icon.paint(painter, icon_rect) - - # Move to next button position - current_x += button_size + spacing - - painter.restore() - - def editorEvent(self, event, model, option, index): - """Handle mouse events for action buttons""" - # Early return if not a left click - if not ( - event.type() == event.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton - ): - return super().editorEvent(event, model, option, index) - - # Check which button was clicked - visible_actions = [action for action in self.macro_actions if action.isVisible()] - for i, button_rect in enumerate(self.button_rects): - if button_rect.contains(event.pos()) and i < len(visible_actions): - # Trigger the action - visible_actions[i].trigger() - return True - - return super().editorEvent(event, model, option, index) - - def set_hovered_index(self, index): - """Set the currently hovered index""" - self.hovered_index = index + return self.macro_actions class MacroTreeWidget(QWidget): diff --git a/bec_widgets/widgets/containers/explorer/script_tree_widget.py b/bec_widgets/widgets/containers/explorer/script_tree_widget.py index 6c8ed5c8f..68ff10353 100644 --- a/bec_widgets/widgets/containers/explorer/script_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/script_tree_widget.py @@ -2,27 +2,23 @@ from pathlib import Path from bec_lib.logger import bec_logger -from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal -from qtpy.QtGui import QPainter -from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget +from qtpy.QtCore import QModelIndex, QRegularExpression, QSortFilterProxyModel, Signal +from qtpy.QtWidgets import QFileSystemModel, QTreeView, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate logger = bec_logger.logger -class FileItemDelegate(QStyledItemDelegate): +class FileItemDelegate(ExplorerDelegate): """Custom delegate to show action buttons on hover""" def __init__(self, tree_widget): super().__init__(tree_widget) - self.setObjectName("file_item_delegate") - self.hovered_index = QModelIndex() self.file_actions = [] self.dir_actions = [] - self.button_rects = [] - self.current_file_path = "" def add_file_action(self, action) -> None: """Add an action for files""" @@ -37,126 +33,18 @@ def clear_actions(self) -> None: self.file_actions.clear() self.dir_actions.clear() - def paint(self, painter, option, index): - """Paint the item with action buttons on hover""" - # Paint the default item - super().paint(painter, option, index) - - # Early return if not hovering over this item - if index != self.hovered_index: - return - - tree_view = self.parent() - if not isinstance(tree_view, QTreeView): - return - - proxy_model = tree_view.model() - if not isinstance(proxy_model, QSortFilterProxyModel): - return - - source_index = proxy_model.mapToSource(index) - source_model = proxy_model.sourceModel() - if not isinstance(source_model, QFileSystemModel): - return - - is_dir = source_model.isDir(source_index) - file_path = source_model.filePath(source_index) - self.current_file_path = file_path - - # Choose appropriate actions based on item type - actions = self.dir_actions if is_dir else self.file_actions - if actions: - self._draw_action_buttons(painter, option, actions) - - def _draw_action_buttons(self, painter, option, actions): - """Draw action buttons on the right side""" - button_size = 18 - margin = 4 - spacing = 2 - - # Calculate total width needed for all buttons - total_width = len(actions) * button_size + (len(actions) - 1) * spacing - - # Clear previous button rects and create new ones - self.button_rects.clear() - - # Calculate starting position (right side of the item) - start_x = option.rect.right() - total_width - margin - current_x = start_x - - painter.save() - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get theme colors for better integration - palette = get_theme_palette() - button_bg = palette.button().color() - button_bg.setAlpha(150) # Semi-transparent - - for action in actions: - if not action.isVisible(): - continue - - # Calculate button position - button_rect = QRect( - current_x, - option.rect.top() + (option.rect.height() - button_size) // 2, - button_size, - button_size, - ) - self.button_rects.append(button_rect) - - # Draw button background - painter.setBrush(button_bg) - painter.setPen(palette.mid().color()) - painter.drawRoundedRect(button_rect, 3, 3) - - # Draw action icon - icon = action.icon() - if not icon.isNull(): - icon_rect = button_rect.adjusted(2, 2, -2, -2) - icon.paint(painter, icon_rect) - - # Move to next button position - current_x += button_size + spacing - - painter.restore() - - def editorEvent(self, event, model, option, index): - """Handle mouse events for action buttons""" - # Early return if not a left click - if not ( - event.type() == event.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton - ): - return super().editorEvent(event, model, option, index) - - # Early return if not a proxy model + def get_actions_for_current_item(self, model, index) -> list[MaterialIconAction] | None: + """Get actions for the current item based on its type""" if not isinstance(model, QSortFilterProxyModel): - return super().editorEvent(event, model, option, index) + return None source_index = model.mapToSource(index) source_model = model.sourceModel() - - # Early return if not a file system model if not isinstance(source_model, QFileSystemModel): - return super().editorEvent(event, model, option, index) + return None is_dir = source_model.isDir(source_index) - actions = self.dir_actions if is_dir else self.file_actions - - # Check which button was clicked - visible_actions = [action for action in actions if action.isVisible()] - for i, button_rect in enumerate(self.button_rects): - if button_rect.contains(event.pos()) and i < len(visible_actions): - # Trigger the action - visible_actions[i].trigger() - return True - - return super().editorEvent(event, model, option, index) - - def set_hovered_index(self, index): - """Set the currently hovered index""" - self.hovered_index = index + return self.dir_actions if is_dir else self.file_actions class ScriptTreeWidget(QWidget): @@ -293,14 +181,14 @@ def eventFilter(self, obj, event): return super().eventFilter(obj, event) - def set_directory(self, directory): + def set_directory(self, directory: str) -> None: """Set the scripts directory""" - self.directory = directory - # Early return if directory doesn't exist - if not directory or not os.path.exists(directory): + if not directory or not isinstance(directory, str) or not os.path.exists(directory): return + self.directory = directory + root_index = self.model.setRootPath(directory) # Map the source model index to proxy model index proxy_root_index = self.proxy_model.mapFromSource(root_index) diff --git a/bec_widgets/widgets/editors/monaco/monaco_tab.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py similarity index 85% rename from bec_widgets/widgets/editors/monaco/monaco_tab.py rename to bec_widgets/widgets/editors/monaco/monaco_dock.py index a73325578..eaee66694 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_tab.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -2,16 +2,16 @@ import os import pathlib -from typing import Any, Literal, cast +from typing import Any, cast -import PySide6QtAds as QtAds from bec_lib.logger import bec_logger from bec_lib.macro_update_handler import has_executable_code -from PySide6QtAds import CDockWidget from qtpy.QtCore import QEvent, QTimer, Signal from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget +import bec_widgets.widgets.containers.ads as QtAds from bec_widgets import BECWidget +from bec_widgets.widgets.containers.ads import CDockAreaWidget, CDockWidget from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget logger = bec_logger.logger @@ -40,13 +40,14 @@ def __init__(self, parent=None, **kwargs): self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event) self._root_layout.addWidget(self.dock_manager) self.dock_manager.installEventFilter(self) - self._last_focused_editor: MonacoWidget | None = None + self._last_focused_editor: CDockWidget | None = None self.focused_editor.connect(self._on_last_focused_editor_changed) self.add_editor() self._open_files = {} def _create_editor(self): - widget = MonacoWidget(self) + init_lsp = len(self.dock_manager.dockWidgets()) == 0 + widget = MonacoWidget(self, init_lsp=init_lsp) widget.save_enabled.connect(self.save_enabled.emit) widget.editor.signature_help_triggered.connect(self._on_signature_change) count = len(self.dock_manager.dockWidgets()) @@ -58,11 +59,11 @@ def _create_editor(self): lambda modified: self._update_tab_title_for_modification(dock, modified) ) - dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True) - dock.setFeature(CDockWidget.CustomCloseHandling, True) - dock.setFeature(CDockWidget.DockWidgetClosable, True) - dock.setFeature(CDockWidget.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetMovable, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) + dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, True) dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget)) @@ -73,6 +74,10 @@ def last_focused_editor(self) -> CDockWidget | None: """ Get the last focused editor. """ + dock_widget = self.dock_manager.focusedDockWidget() + if dock_widget is not None and isinstance(dock_widget.widget(), MonacoWidget): + self.last_focused_editor = dock_widget + return self._last_focused_editor @last_focused_editor.setter @@ -199,7 +204,7 @@ def _ensure_area_plus(self, area): def _scan_and_fix_areas(self): # Find all dock areas under this manager and ensure each single-tab area has a plus button - areas = self.dock_manager.findChildren(QtAds.CDockAreaWidget) + areas = self.dock_manager.findChildren(CDockAreaWidget) for a in areas: self._ensure_area_plus(a) @@ -226,7 +231,9 @@ def add_editor( if tooltip is not None: new_dock.setTabToolTip(tooltip) if area is None: - area_obj = self.dock_manager.addDockWidgetTab(QtAds.TopDockWidgetArea, new_dock) + area_obj = self.dock_manager.addDockWidgetTab( + QtAds.DockWidgetArea.TopDockWidgetArea, new_dock + ) self._ensure_area_plus(area_obj) else: # If an area is provided, add the dock to that area @@ -253,8 +260,13 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: # For now, the dock manager is only for the editor docks. We can therefore safely assume # that all docks are editor docks. dock_area = self.dock_manager.dockArea(0) + if not dock_area: + return editor_dock = dock_area.currentDockWidget() + if not editor_dock: + return + editor_widget = editor_dock.widget() if editor_dock else None if editor_widget: editor_widget = cast(MonacoWidget, editor_dock.widget()) @@ -262,14 +274,17 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: editor_dock.setWindowTitle(file) editor_dock.setTabToolTip(file_name) editor_widget.open_file(file_name) - editor_widget.metadata["scope"] = scope + if scope is not None: + editor_widget.metadata["scope"] = scope return # File is not open, create a new editor editor_dock = self.add_editor(title=file, tooltip=file_name) widget = cast(MonacoWidget, editor_dock.widget()) widget.open_file(file_name) - widget.metadata["scope"] = scope + if scope is not None: + widget.metadata["scope"] = scope + editor_dock.setAsCurrentTab() def save_file( self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True @@ -309,35 +324,36 @@ def save_file( # Save as option save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)") - if save_file: - # check if we have suffix specified - file = pathlib.Path(save_file[0]) - if file.suffix == "": - file = file.with_suffix(".py") - if format_on_save and file.suffix == ".py": - widget.format() - - text = widget.get_text() - with open(file, "w", encoding="utf-8") as f: - f.write(text) - widget._original_content = text - - # Update the current_file before emitting save_enabled to ensure proper tracking - widget._current_file = str(file) - widget.save_enabled.emit(False) - - # Find the dock widget containing this monaco widget and update title - for dock in self.dock_manager.dockWidgets(): - if dock.widget() == widget: - dock.setWindowTitle(file.name) - dock.setTabToolTip(str(file)) - break - if "macros" in widget.metadata.get("scope", ""): - self._update_macros(widget) - # Emit signal to refresh macro tree widget - self.macro_file_updated.emit(str(file)) + if not save_file or not save_file[0]: + return + # check if we have suffix specified + file = pathlib.Path(save_file[0]) + if file.suffix == "": + file = file.with_suffix(".py") + if format_on_save and file.suffix == ".py": + widget.format() + + text = widget.get_text() + with open(file, "w", encoding="utf-8") as f: + f.write(text) + widget._original_content = text + + # Update the current_file before emitting save_enabled to ensure proper tracking + widget._current_file = str(file) + widget.save_enabled.emit(False) + + # Find the dock widget containing this monaco widget and update title + for dock in self.dock_manager.dockWidgets(): + if dock.widget() == widget: + dock.setWindowTitle(file.name) + dock.setTabToolTip(str(file)) + break + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(str(file)) - print(f"Save file called, last focused editor: {self.last_focused_editor}") + logger.debug(f"Save file called, last focused editor: {self.last_focused_editor}") def _validate_macros(self, source: str) -> bool: # pylint: disable=protected-access @@ -398,7 +414,7 @@ def _get_open_files(self) -> list[str]: open_files.append(editor_widget.current_file) return open_files - def _get_editor_dock(self, file_name: str) -> CDockWidget | None: + def _get_editor_dock(self, file_name: str) -> QtAds.CDockWidget | None: for widget in self.dock_manager.dockWidgets(): editor_widget = cast(MonacoWidget, widget.widget()) if editor_widget.current_file == file_name: diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index dbfc9d5ab..25fd2b3d8 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import os import traceback -from typing import Literal +from typing import TYPE_CHECKING, Literal import black import isort @@ -13,6 +15,9 @@ from bec_widgets.utils.colors import get_theme_name from bec_widgets.utils.error_popups import SafeSlot +if TYPE_CHECKING: + from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + logger = bec_logger.logger @@ -47,7 +52,9 @@ class MonacoWidget(BECWidget, QWidget): "screenshot", ] - def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + def __init__( + self, parent=None, config=None, client=None, gui_id=None, init_lsp: bool = True, **kwargs + ): super().__init__( parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs ) @@ -64,6 +71,16 @@ def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs) self._current_file = None self._original_content = "" self.metadata = {} + if init_lsp: + self.editor.update_workspace_configuration( + { + "pylsp": { + "plugins": { + "pylsp-bec": {"service_config": self.client._service_config.config} + } + } + } + ) @property def current_file(self): @@ -84,16 +101,18 @@ def apply_theme(self, theme: str | None = None) -> None: editor_theme = "vs" if theme == "light" else "vs-dark" self.set_theme(editor_theme) - def set_text(self, text: str, file_name: str | None = None) -> None: + def set_text(self, text: str, file_name: str | None = None, reset: bool = False) -> None: """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. file_name (str): Set the file name + reset (bool): If True, reset the original content to the new text. """ - self._current_file = file_name - self._original_content = text + self._current_file = file_name if file_name else self._current_file + if reset: + self._original_content = text self.editor.set_text(text, uri=file_name) def get_text(self) -> str: @@ -161,7 +180,7 @@ def open_file(self, file_name: str) -> None: with open(file_name, "r", encoding="utf-8") as file: content = file.read() - self.set_text(content, file_name=file_name) + self.set_text(content, file_name=file_name, reset=True) @property def modified(self) -> bool: @@ -311,6 +330,16 @@ def _show_scan_control_dialog(self): from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog dialog = ScanControlDialog(self, client=self.client) + self._run_dialog_and_insert_code(dialog) + + def _run_dialog_and_insert_code(self, dialog: ScanControlDialog): + """ + Run the dialog and insert the generated scan code if accepted. + It is a separate method to allow easier testing. + + Args: + dialog (ScanControlDialog): The scan control dialog instance. + """ if dialog.exec_() == QDialog.DialogCode.Accepted: scan_code = dialog.get_scan_code() if scan_code: diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index bec91e447..d5eda4b5b 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -3,6 +3,7 @@ import importlib.metadata import os import re +from typing import Literal from bec_qthemes import material_icon from qtpy.QtCore import Signal @@ -65,6 +66,17 @@ def _add_section(self, section_name): case _: pass + def _remove_section(self, section_name): + section = self.main_explorer.get_section(section_name.upper()) + if section: + self.main_explorer.remove_section(section) + self._sections.remove(section_name) + + def clear(self): + """Clear all sections from the explorer.""" + for section in reversed(self._sections): + self._remove_section(section) + def add_script_section(self): section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0) @@ -84,13 +96,7 @@ def add_script_section(self): section.set_widget(script_explorer) self.main_explorer.add_section(section) - plugin_scripts_dir = None - plugins = importlib.metadata.entry_points(group="bec") - for plugin in plugins: - if plugin.name == "plugin_bec": - plugin = plugin.load() - plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts") - break + plugin_scripts_dir = self._get_plugin_dir("scripts") if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir): return @@ -102,9 +108,6 @@ def add_script_section(self): script_explorer.add_section(shared_script_section) shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared) shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared) - # macros_section = CollapsibleSection("MACROS", indentation=0) - # macros_section.set_widget(QLabel("Macros will be implemented later")) - # self.main_explorer.add_section(macros_section) def add_macro_section(self): section = CollapsibleSection( @@ -134,13 +137,7 @@ def add_macro_section(self): section.set_widget(macro_explorer) self.main_explorer.add_section(section) - plugin_macros_dir = None - plugins = importlib.metadata.entry_points(group="bec") - for plugin in plugins: - if plugin.name == "plugin_bec": - plugin = plugin.load() - plugin_macros_dir = os.path.join(plugin.__path__[0], "macros") - break + plugin_macros_dir = self._get_plugin_dir("macros") if not plugin_macros_dir or not os.path.exists(plugin_macros_dir): return @@ -153,6 +150,19 @@ def add_macro_section(self): shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared) shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared) + def _get_plugin_dir(self, dir_name: Literal["scripts", "macros"]) -> str | None: + """Get the path to the specified directory within the BEC plugin. + + Returns: + The path to the specified directory, or None if not found. + """ + plugins = importlib.metadata.entry_points(group="bec") + for plugin in plugins: + if plugin.name == "plugin_bec": + plugin = plugin.load() + return os.path.join(plugin.__path__[0], dir_name) + return None + def _emit_file_open_scripts_local(self, file_name: str): self.file_open_requested.emit(file_name, "scripts/local") @@ -281,7 +291,7 @@ def {function_name}(): Add your macro implementation here. """ - print(f"Executing macro: {function_name}") + print("Executing macro: {function_name}") # TODO: Add your macro code here pass ''' diff --git a/pyproject.toml b/pyproject.toml index 1bb448e93..ec81c2528 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,12 +24,13 @@ dependencies = [ "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", "thefuzz~=0.22", - "qtmonaco~=0.7", + "qtmonaco~=0.8, >=0.8.1", "darkdetect~=0.8", "PySide6-QtAds==4.4.0", - "pylsp-bec", + "pylsp-bec~=1.2", "copier~=9.7", "typer~=0.15", + "markdown~=3.9", ] diff --git a/tests/unit_tests/test_collapsible_tree_section.py b/tests/unit_tests/test_collapsible_tree_section.py new file mode 100644 index 000000000..028f5fe03 --- /dev/null +++ b/tests/unit_tests/test_collapsible_tree_section.py @@ -0,0 +1,119 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +from unittest import mock + +import pytest +from qtpy.QtCore import QMimeData, QPoint, Qt +from qtpy.QtWidgets import QLabel + +from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection + + +@pytest.fixture +def collapsible_section(qtbot): + """Create a basic CollapsibleSection widget for testing""" + widget = CollapsibleSection(title="Test Section") + qtbot.addWidget(widget) + yield widget + + +@pytest.fixture +def dummy_content_widget(qtbot): + """Create a simple widget to be used as content""" + widget = QLabel("Test Content") + qtbot.addWidget(widget) + return widget + + +def test_basic_initialization(collapsible_section): + """Test basic initialization""" + assert collapsible_section.title == "Test Section" + assert collapsible_section.expanded is True + assert collapsible_section.content_widget is None + + +def test_toggle_expanded(collapsible_section): + """Test toggling expansion state""" + assert collapsible_section.expanded is True + collapsible_section.toggle_expanded() + assert collapsible_section.expanded is False + collapsible_section.toggle_expanded() + assert collapsible_section.expanded is True + + +def test_set_widget(collapsible_section, dummy_content_widget): + """Test setting content widget""" + collapsible_section.set_widget(dummy_content_widget) + assert collapsible_section.content_widget == dummy_content_widget + assert dummy_content_widget.parent() == collapsible_section + + +def test_connect_add_button(qtbot): + """Test connecting add button""" + widget = CollapsibleSection(title="Test", show_add_button=True) + qtbot.addWidget(widget) + + mock_slot = mock.MagicMock() + widget.connect_add_button(mock_slot) + + qtbot.mouseClick(widget.header_add_button, Qt.MouseButton.LeftButton) + mock_slot.assert_called_once() + + +def test_section_reorder_signal(collapsible_section): + """Test section reorder signal emission""" + signals_received = [] + collapsible_section.section_reorder_requested.connect( + lambda source, target: signals_received.append((source, target)) + ) + + # Create mock drop event + mime_data = QMimeData() + mime_data.setText("section:Source Section") + + mock_event = mock.MagicMock() + mock_event.mimeData.return_value = mime_data + + collapsible_section._header_drop_event(mock_event) + + assert len(signals_received) == 1 + assert signals_received[0] == ("Source Section", "Test Section") + + +def test_nested_collapsible_sections(qtbot): + """Test that collapsible sections can be nested""" + # Create parent section + parent_section = CollapsibleSection(title="Parent Section") + qtbot.addWidget(parent_section) + + # Create child section + child_section = CollapsibleSection(title="Child Section") + qtbot.addWidget(child_section) + + # Add some content to the child section + child_content = QLabel("Child Content") + qtbot.addWidget(child_content) + child_section.set_widget(child_content) + + # Nest the child section inside the parent + parent_section.set_widget(child_section) + + # Verify nesting structure + assert parent_section.content_widget == child_section + assert child_section.parent() == parent_section + assert child_section.content_widget == child_content + assert child_content.parent() == child_section + + # Test that both sections can expand/collapse independently + assert parent_section.expanded is True + assert child_section.expanded is True + + # Collapse child section + child_section.toggle_expanded() + assert child_section.expanded is False + assert parent_section.expanded is True # Parent should remain expanded + + # Collapse parent section + parent_section.toggle_expanded() + assert parent_section.expanded is False + assert child_section.expanded is False # Child state unchanged diff --git a/tests/unit_tests/test_developer_view.py b/tests/unit_tests/test_developer_view.py new file mode 100644 index 000000000..6a4d5d9af --- /dev/null +++ b/tests/unit_tests/test_developer_view.py @@ -0,0 +1,378 @@ +""" +Unit tests for the Developer View widget. + +This module tests the DeveloperView widget functionality including: +- Widget initialization and setup +- Monaco editor integration +- IDE Explorer integration +- File operations (open, save, format) +- Context menu actions +- Toolbar functionality +""" + +import os +import tempfile +from unittest import mock + +import pytest +from qtpy.QtWidgets import QDialog + +from bec_widgets.examples.developer_view.developer_widget import DeveloperWidget +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + +from .client_mocks import mocked_client + + +@pytest.fixture +def developer_view(qtbot, mocked_client): + """Create a DeveloperWidget for testing.""" + widget = DeveloperWidget(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +@pytest.fixture +def temp_python_file(): + """Create a temporary Python file for testing.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write( + """# Test Python file +import os +import sys + +def test_function(): + return "Hello, World!" + +if __name__ == "__main__": + print(test_function()) +""" + ) + temp_file_path = f.name + + yield temp_file_path + + # Cleanup + if os.path.exists(temp_file_path): + os.unlink(temp_file_path) + + +@pytest.fixture +def mock_scan_control_dialog(): + """Mock the ScanControlDialog for testing.""" + with mock.patch( + "bec_widgets.widgets.editors.monaco.scan_control_dialog.ScanControlDialog" + ) as mock_dialog: + # Configure the mock dialog + mock_dialog_instance = mock.MagicMock() + mock_dialog_instance.exec_.return_value = QDialog.DialogCode.Accepted + mock_dialog_instance.get_scan_code.return_value = ( + "scans.ascan(dev.samx, 0, 1, 10, exp_time=0.1)" + ) + mock_dialog.return_value = mock_dialog_instance + yield mock_dialog_instance + + +class TestDeveloperViewInitialization: + """Test developer view initialization and basic functionality.""" + + def test_developer_view_initialization(self, developer_view): + """Test that the developer view initializes correctly.""" + # Check that main components are created + assert hasattr(developer_view, "monaco") + assert hasattr(developer_view, "explorer") + assert hasattr(developer_view, "console") + assert hasattr(developer_view, "terminal") + assert hasattr(developer_view, "toolbar") + assert hasattr(developer_view, "dock_manager") + assert hasattr(developer_view, "plotting_ads") + assert hasattr(developer_view, "signature_help") + + def test_monaco_editor_integration(self, developer_view): + """Test that Monaco editor is properly integrated.""" + assert isinstance(developer_view.monaco, MonacoDock) + assert developer_view.monaco.parent() is not None + + def test_ide_explorer_integration(self, developer_view): + """Test that IDE Explorer is properly integrated.""" + assert isinstance(developer_view.explorer, IDEExplorer) + assert developer_view.explorer.parent() is not None + + def test_toolbar_components(self, developer_view): + """Test that toolbar components are properly set up.""" + assert developer_view.toolbar is not None + + # Check for expected toolbar actions + toolbar_components = developer_view.toolbar.components + expected_actions = ["save", "save_as", "run", "stop", "vim"] + + for action_name in expected_actions: + assert toolbar_components.exists(action_name) + + def test_dock_manager_setup(self, developer_view): + """Test that dock manager is properly configured.""" + assert developer_view.dock_manager is not None + + # Check that docks are added + dock_widgets = developer_view.dock_manager.dockWidgets() + assert len(dock_widgets) >= 4 # Explorer, Monaco, Console, Terminal + + +class TestFileOperations: + """Test file operation functionality.""" + + def test_open_new_file(self, developer_view, temp_python_file, qtbot): + """Test opening a new file in the Monaco editor.""" + # Simulate opening a file through the IDE explorer signal + developer_view._open_new_file(temp_python_file, "scripts/local") + + # Wait for the file to be loaded + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that the file was opened + assert temp_python_file in developer_view.monaco._get_open_files() + + # Check that content was loaded (simplified check) + # Get the editor dock for the file and check its content + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + editor_widget = dock.widget() + assert "test_function" in editor_widget.get_text() + + def test_open_shared_file_readonly(self, developer_view, temp_python_file, qtbot): + """Test that shared files are opened in read-only mode.""" + # Open file with shared scope + developer_view._open_new_file(temp_python_file, "scripts/shared") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that the file is set to read-only + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + monaco_widget = dock.widget() + # Check that the widget is in read-only mode + # This depends on MonacoWidget having a readonly property or method + assert monaco_widget is not None + + def test_file_icon_assignment(self, developer_view, temp_python_file, qtbot): + """Test that file icons are assigned based on scope.""" + # Test script file icon + developer_view._open_new_file(temp_python_file, "scripts/local") + + qtbot.waitUntil( + lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000 + ) + + # Check that an icon was set (simplified check) + dock = developer_view.monaco._get_editor_dock(temp_python_file) + if dock: + assert not dock.icon().isNull() + + def test_save_functionality(self, developer_view, qtbot): + """Test the save functionality.""" + # Get the currently focused editor widget (if any) + if developer_view.monaco.last_focused_editor: + editor_widget = developer_view.monaco.last_focused_editor.widget() + test_text = "print('Hello from save test')" + editor_widget.set_text(test_text) + + qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000) + + # Test the save action + with mock.patch.object(developer_view.monaco, "save_file") as mock_save: + developer_view.on_save() + mock_save.assert_called_once() + + def test_save_as_functionality(self, developer_view, qtbot): + """Test the save as functionality.""" + # Get the currently focused editor widget (if any) + if developer_view.monaco.last_focused_editor: + editor_widget = developer_view.monaco.last_focused_editor.widget() + test_text = "print('Hello from save as test')" + editor_widget.set_text(test_text) + + qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000) + + # Test the save as action + with mock.patch.object(developer_view.monaco, "save_file") as mock_save: + developer_view.on_save_as() + mock_save.assert_called_once_with(force_save_as=True) + + +class TestMonacoEditorIntegration: + """Test Monaco editor specific functionality.""" + + def test_vim_mode_toggle(self, developer_view, qtbot): + """Test vim mode toggle functionality.""" + # Test enabling vim mode + with mock.patch.object(developer_view.monaco, "set_vim_mode") as mock_vim: + developer_view.on_vim_triggered() + # The actual call depends on the checkbox state + mock_vim.assert_called_once() + + def test_context_menu_insert_scan(self, developer_view, mock_scan_control_dialog, qtbot): + """Test the Insert Scan context menu action.""" + # This functionality is handled by individual MonacoWidget instances + # Test that the dock has editor widgets + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + assert len(dock_widgets) >= 1 + + # Test on the first available editor + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + assert isinstance(monaco_widget, MonacoWidget) + + def test_context_menu_format_code(self, developer_view, qtbot): + """Test the Format Code context menu action.""" + # Get an editor widget from the dock manager + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + if dock_widgets: + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + + # Set some unformatted Python code + unformatted_code = "import os,sys\ndef test():\n x=1+2\n return x" + monaco_widget.set_text(unformatted_code) + + qtbot.waitUntil(lambda: monaco_widget.get_text() == unformatted_code, timeout=1000) + + # Test format action on the individual widget + with mock.patch.object(monaco_widget, "format") as mock_format: + monaco_widget.format() + mock_format.assert_called_once() + + def test_save_enabled_signal_handling(self, developer_view, qtbot): + """Test that save enabled signals are handled correctly.""" + # Mock the toolbar update method + with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update: + # Simulate save enabled signal + developer_view.monaco.save_enabled.emit(True) + mock_update.assert_called_with(True) + + developer_view.monaco.save_enabled.emit(False) + mock_update.assert_called_with(False) + + +class TestIDEExplorerIntegration: + """Test IDE Explorer integration.""" + + def test_file_open_signal_connection(self, developer_view): + """Test that file open signals are properly connected.""" + # Test that the signal connection works by mocking the connected method + with mock.patch.object(developer_view, "_open_new_file") as mock_open: + # Emit the signal to test the connection + developer_view.explorer.file_open_requested.emit("test_file.py", "scripts/local") + mock_open.assert_called_once_with("test_file.py", "scripts/local") + + def test_file_preview_signal_connection(self, developer_view): + """Test that file preview signals are properly connected.""" + # Test that the signal exists and can be emitted (basic connection test) + try: + developer_view.explorer.file_preview_requested.emit("test_file.py", "scripts/local") + # If no exception is raised, the signal exists and is connectable + assert True + except AttributeError: + assert False, "file_preview_requested signal not found" + + def test_sections_configuration(self, developer_view): + """Test that IDE Explorer sections are properly configured.""" + assert "scripts" in developer_view.explorer.sections + assert "macros" in developer_view.explorer.sections + + +class TestToolbarIntegration: + """Test toolbar functionality and integration.""" + + def test_toolbar_save_button_state(self, developer_view): + """Test toolbar save button state management.""" + # Test that save buttons exist and can be controlled + save_action = developer_view.toolbar.components.get_action("save") + save_as_action = developer_view.toolbar.components.get_action("save_as") + + # Test that the actions exist and are accessible + assert save_action.action is not None + assert save_as_action.action is not None + + # Test that they can be enabled/disabled via the update method + developer_view._on_save_enabled_update(False) + assert not save_action.action.isEnabled() + assert not save_as_action.action.isEnabled() + + developer_view._on_save_enabled_update(True) + assert save_action.action.isEnabled() + assert save_as_action.action.isEnabled() + + def test_vim_mode_button_toggle(self, developer_view, qtbot): + """Test vim mode button toggle functionality.""" + vim_action = developer_view.toolbar.components.get_action("vim") + + if vim_action: + # Test toggling vim mode + initial_state = vim_action.action.isChecked() + + # Simulate button click + vim_action.action.trigger() + + # Check that state changed + assert vim_action.action.isChecked() != initial_state + + +class TestErrorHandling: + """Test error handling in various scenarios.""" + + def test_invalid_scope_handling(self, developer_view, temp_python_file): + """Test handling of invalid scope parameters.""" + # Test with invalid scope + try: + developer_view._open_new_file(temp_python_file, "invalid/scope") + except Exception as e: + assert False, f"Invalid scope should be handled gracefully: {e}" + + def test_monaco_editor_error_handling(self, developer_view): + """Test error handling in Monaco editor operations.""" + # Test with editor widgets from dock manager + dock_widgets = developer_view.monaco.dock_manager.dockWidgets() + if dock_widgets: + first_dock = dock_widgets[0] + monaco_widget = first_dock.widget() + + # Test setting invalid text + try: + monaco_widget.set_text(None) # This might cause an error + except Exception: + # Errors should be handled gracefully + pass + + +class TestSignalIntegration: + """Test signal connections and data flow.""" + + def test_file_open_signal_flow(self, developer_view, temp_python_file, qtbot): + """Test the complete file open signal flow.""" + # Mock the _open_new_file method to verify it gets called + with mock.patch.object(developer_view, "_open_new_file") as mock_open: + # Emit the file open signal from explorer + developer_view.explorer.file_open_requested.emit(temp_python_file, "scripts/local") + + # Verify the signal was handled + mock_open.assert_called_once_with(temp_python_file, "scripts/local") + + def test_save_enabled_signal_flow(self, developer_view, qtbot): + """Test the save enabled signal flow.""" + # Mock the update method (the actual method is _on_save_enabled_update) + with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update: + # Simulate monaco dock emitting save enabled signal + developer_view.monaco.save_enabled.emit(True) + + # Verify the signal was handled + mock_update.assert_called_once_with(True) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/unit_tests/test_ide_explorer.py b/tests/unit_tests/test_ide_explorer.py index ba1b9eecc..cfdf3d5f0 100644 --- a/tests/unit_tests/test_ide_explorer.py +++ b/tests/unit_tests/test_ide_explorer.py @@ -1,7 +1,9 @@ import os +from pathlib import Path from unittest import mock import pytest +from qtpy.QtWidgets import QMessageBox from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -34,3 +36,423 @@ def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir): ): ide_explorer._add_local_script() assert os.path.exists(os.path.join(tmpdir, "test_file.py")) + + +def test_shared_scripts_section_with_files(ide_explorer, tmpdir): + """Test that shared scripts section is created when plugin directory has files""" + # Create dummy shared script files + shared_scripts_dir = tmpdir.mkdir("shared_scripts") + shared_scripts_dir.join("shared_script1.py").write("# Shared script 1") + shared_scripts_dir.join("shared_script2.py").write("# Shared script 2") + + ide_explorer.clear() + + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = str(shared_scripts_dir) + + ide_explorer.add_script_section() + + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + assert scripts_section is not None + + # Should have both Local and Shared sections + local_section = scripts_section.content_widget.get_section("Local") + shared_section = scripts_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is not None + assert "read-only" in shared_section.toolTip().lower() + + +def test_shared_macros_section_with_files(ide_explorer, tmpdir): + """Test that shared macros section is created when plugin directory has files""" + # Create dummy shared macro files + shared_macros_dir = tmpdir.mkdir("shared_macros") + shared_macros_dir.join("shared_macro1.py").write( + """ +def shared_function1(): + return "shared1" + +def shared_function2(): + return "shared2" +""" + ) + shared_macros_dir.join("utilities.py").write( + """ +def utility_function(): + return "utility" +""" + ) + + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = str(shared_macros_dir) + + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + macros_section = ide_explorer.main_explorer.get_section("MACROS") + assert macros_section is not None + + # Should have both Local and Shared sections + local_section = macros_section.content_widget.get_section("Local") + shared_section = macros_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is not None + assert "read-only" in shared_section.toolTip().lower() + + +def test_shared_sections_not_added_when_plugin_dir_missing(ide_explorer): + """Test that shared sections are not added when plugin directories don't exist""" + ide_explorer.clear() + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = None + + ide_explorer.add_script_section() + + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + assert scripts_section is not None + + # Should only have Local section + local_section = scripts_section.content_widget.get_section("Local") + shared_section = scripts_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is None + + +def test_shared_sections_not_added_when_directory_empty(ide_explorer, tmpdir): + """Test that shared sections are not added when plugin directory doesn't exist on disk""" + ide_explorer.clear() + # Return a path that doesn't exist + nonexistent_path = str(tmpdir.join("nonexistent")) + + with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir: + mock_get_plugin_dir.return_value = nonexistent_path + + ide_explorer.add_script_section() + + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + assert scripts_section is not None + + # Should only have Local section since directory doesn't exist + local_section = scripts_section.content_widget.get_section("Local") + shared_section = scripts_section.content_widget.get_section("Shared (Read-only)") + + assert local_section is not None + assert shared_section is None + + +@pytest.mark.parametrize( + "slot, signal, file_name,scope", + [ + ( + "_emit_file_open_scripts_local", + "file_open_requested", + "example_script.py", + "scripts/local", + ), + ( + "_emit_file_preview_scripts_local", + "file_preview_requested", + "example_macro.py", + "scripts/local", + ), + ( + "_emit_file_open_scripts_shared", + "file_open_requested", + "example_script.py", + "scripts/shared", + ), + ( + "_emit_file_preview_scripts_shared", + "file_preview_requested", + "example_macro.py", + "scripts/shared", + ), + ], +) +def test_ide_explorer_file_signals(ide_explorer, qtbot, slot, signal, file_name, scope): + """Test that the correct signals are emitted when files are opened or previewed""" + recv = [] + + def recv_file_signal(file_name, scope): + recv.append((file_name, scope)) + + sig = getattr(ide_explorer, signal) + sig.connect(recv_file_signal) + # Call the appropriate slot + getattr(ide_explorer, slot)(file_name) + qtbot.wait(300) + # Verify the signal was emitted with correct arguments + assert recv == [(file_name, scope)] + + +@pytest.mark.parametrize( + "slot, signal, func_name, file_path,scope", + [ + ( + "_emit_file_open_macros_local", + "file_open_requested", + "example_macro_function", + "macros/local/example_macro.py", + "macros/local", + ), + ( + "_emit_file_preview_macros_local", + "file_preview_requested", + "example_macro_function", + "macros/local/example_macro.py", + "macros/local", + ), + ( + "_emit_file_open_macros_shared", + "file_open_requested", + "example_macro_function", + "macros/shared/example_macro.py", + "macros/shared", + ), + ( + "_emit_file_preview_macros_shared", + "file_preview_requested", + "example_macro_function", + "macros/shared/example_macro.py", + "macros/shared", + ), + ], +) +def test_ide_explorer_file_signals_macros( + ide_explorer, qtbot, slot, signal, func_name, file_path, scope +): + """Test that the correct signals are emitted when macro files are opened or previewed""" + recv = [] + + def recv_file_signal(file_name, scope): + recv.append((file_name, scope)) + + sig = getattr(ide_explorer, signal) + sig.connect(recv_file_signal) + # Call the appropriate slot + getattr(ide_explorer, slot)(func_name, file_path) + qtbot.wait(300) + # Verify the signal was emitted with correct arguments + assert recv == [(file_path, scope)] + + +def test_ide_explorer_add_local_macro(ide_explorer, qtbot, tmpdir): + """Test adding a local macro through the UI""" + # Create macros section first + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Set up the local macro directory + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("test_macro_function", True), + ): + ide_explorer._add_local_macro() + + # Check that the macro file was created + expected_file = os.path.join(tmpdir, "test_macro_function.py") + assert os.path.exists(expected_file) + + # Check that the file contains the expected function + with open(expected_file, "r") as f: + content = f.read() + assert "def test_macro_function():" in content + assert "test_macro_function macro" in content + + +def test_ide_explorer_add_local_macro_invalid_name(ide_explorer, qtbot, tmpdir): + """Test adding a local macro with invalid function name""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Test with invalid function name (starts with number) + with ( + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("123invalid", True), + ), + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.warning" + ) as mock_warning, + ): + ide_explorer._add_local_macro() + + # Should show warning message + mock_warning.assert_called_once() + + # Should not create any file + assert len(os.listdir(tmpdir)) == 0 + + +def test_ide_explorer_add_local_macro_file_exists(ide_explorer, qtbot, tmpdir): + """Test adding a local macro when file already exists""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Create an existing file + existing_file = Path(tmpdir) / "existing_macro.py" + existing_file.write_text("# Existing macro") + + with ( + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("existing_macro", True), + ), + mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.question", + return_value=QMessageBox.StandardButton.Yes, + ) as mock_question, + ): + ide_explorer._add_local_macro() + + # Should ask for overwrite confirmation + mock_question.assert_called_once() + + # File should be overwritten with new content + with open(existing_file, "r") as f: + content = f.read() + assert "def existing_macro():" in content + + +def test_ide_explorer_add_local_macro_cancelled(ide_explorer, qtbot, tmpdir): + """Test cancelling the add local macro dialog""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # User cancels the dialog + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("", False), # User cancelled + ): + ide_explorer._add_local_macro() + + # Should not create any file + assert len(os.listdir(tmpdir)) == 0 + + +def test_ide_explorer_reload_macros_success(ide_explorer, qtbot): + """Test successful macro reloading""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Mock the client and macros + mock_client = mock.MagicMock() + mock_macros = mock.MagicMock() + mock_client.macros = mock_macros + ide_explorer.client = mock_client + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.information" + ) as mock_info: + ide_explorer._reload_macros() + + # Should call load_all_user_macros + mock_macros.load_all_user_macros.assert_called_once() + + # Should show success message + mock_info.assert_called_once() + assert "successfully" in mock_info.call_args[0][2] + + +def test_ide_explorer_reload_macros_error(ide_explorer, qtbot): + """Test macro reloading when an error occurs""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Mock client with macros that raises an exception + mock_client = mock.MagicMock() + mock_macros = mock.MagicMock() + mock_macros.load_all_user_macros.side_effect = Exception("Test error") + mock_client.macros = mock_macros + ide_explorer.client = mock_client + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.critical" + ) as mock_critical: + ide_explorer._reload_macros() + + # Should show error message + mock_critical.assert_called_once() + assert "Failed to reload macros" in mock_critical.call_args[0][2] + + +def test_ide_explorer_refresh_macro_file_local(ide_explorer, qtbot, tmpdir): + """Test refreshing a local macro file""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Set up the local macro directory + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Create a test macro file + macro_file = Path(tmpdir) / "test_macro.py" + macro_file.write_text("def test_function(): pass") + + # Mock the refresh_file_item method + with mock.patch.object( + local_macros_section.content_widget, "refresh_file_item" + ) as mock_refresh: + ide_explorer.refresh_macro_file(str(macro_file)) + + # Should call refresh_file_item with the file path + mock_refresh.assert_called_once_with(str(macro_file)) + + +def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir): + """Test refreshing a macro file that doesn't match any directory""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + # Set up the local macro directory + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + # Try to refresh a file that's not in any macro directory + unrelated_file = "/some/other/path/unrelated.py" + + # Mock the refresh_file_item method + with mock.patch.object( + local_macros_section.content_widget, "refresh_file_item" + ) as mock_refresh: + ide_explorer.refresh_macro_file(unrelated_file) + + # Should not call refresh_file_item + mock_refresh.assert_not_called() + + +def test_ide_explorer_refresh_macro_file_no_sections(ide_explorer, qtbot): + """Test refreshing a macro file when no macro sections exist""" + ide_explorer.clear() + # Don't add macros section + + # Should handle gracefully without error + ide_explorer.refresh_macro_file("/some/path/test.py") + # Test passes if no exception is raised diff --git a/tests/unit_tests/test_macro_tree_widget.py b/tests/unit_tests/test_macro_tree_widget.py new file mode 100644 index 000000000..501836cb0 --- /dev/null +++ b/tests/unit_tests/test_macro_tree_widget.py @@ -0,0 +1,548 @@ +""" +Unit tests for the MacroTreeWidget. +""" + +from pathlib import Path + +import pytest +from qtpy.QtCore import QEvent, QModelIndex, Qt +from qtpy.QtGui import QMouseEvent + +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget + + +@pytest.fixture +def temp_macro_files(tmpdir): + """Create temporary macro files for testing.""" + macro_dir = Path(tmpdir) / "macros" + macro_dir.mkdir() + + # Create a simple macro file with functions + macro_file1 = macro_dir / "test_macros.py" + macro_file1.write_text( + ''' +def test_macro_function(): + """A test macro function.""" + return "test" + +def another_function(param1, param2): + """Another function with parameters.""" + return param1 + param2 + +class TestClass: + """This class should be ignored.""" + def method(self): + pass +''' + ) + + # Create another macro file + macro_file2 = macro_dir / "utils_macros.py" + macro_file2.write_text( + ''' +def utility_function(): + """A utility function.""" + pass + +def deprecated_function(): + """Old function.""" + return None +''' + ) + + # Create a file with no functions (should be ignored) + empty_file = macro_dir / "empty.py" + empty_file.write_text( + """ +# Just a comment +x = 1 +y = 2 +""" + ) + + # Create a file starting with underscore (should be ignored) + private_file = macro_dir / "_private.py" + private_file.write_text( + """ +def private_function(): + return "private" +""" + ) + + # Create a file with syntax errors + error_file = macro_dir / "error_file.py" + error_file.write_text( + """ +def broken_function( + # Missing closing parenthesis and colon + pass +""" + ) + + return macro_dir + + +@pytest.fixture +def macro_tree(qtbot, temp_macro_files): + """Create a MacroTreeWidget with test macro files.""" + widget = MacroTreeWidget() + widget.set_directory(str(temp_macro_files)) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class TestMacroTreeWidgetInitialization: + """Test macro tree widget initialization and basic functionality.""" + + def test_initialization(self, qtbot): + """Test that the macro tree widget initializes correctly.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + # Check basic properties + assert widget.tree is not None + assert widget.model is not None + assert widget.delegate is not None + assert widget.directory is None + + # Check that tree is configured properly + assert widget.tree.isHeaderHidden() + assert widget.tree.rootIsDecorated() + assert not widget.tree.editTriggers() + + def test_set_directory_with_valid_path(self, macro_tree, temp_macro_files): + """Test setting a valid directory path.""" + assert macro_tree.directory == str(temp_macro_files) + + # Check that files were loaded + assert macro_tree.model.rowCount() > 0 + + # Should have 2 files (test_macros.py and utils_macros.py) + # empty.py and _private.py should be filtered out + expected_files = ["test_macros", "utils_macros"] + actual_files = [] + + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + actual_files.append(item.text()) + + # Sort for consistent comparison + actual_files.sort() + expected_files.sort() + + for expected in expected_files: + assert expected in actual_files + + def test_set_directory_with_invalid_path(self, qtbot): + """Test setting an invalid directory path.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + widget.set_directory("/nonexistent/path") + + # Should handle gracefully + assert widget.directory == "/nonexistent/path" + assert widget.model.rowCount() == 0 + + def test_set_directory_with_none(self, qtbot): + """Test setting directory to None.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + widget.set_directory(None) + + # Should handle gracefully + assert widget.directory is None + assert widget.model.rowCount() == 0 + + +class TestMacroFunctionParsing: + """Test macro function parsing and AST functionality.""" + + def test_extract_functions_from_file(self, macro_tree, temp_macro_files): + """Test extracting functions from a Python file.""" + test_file = temp_macro_files / "test_macros.py" + functions = macro_tree._extract_functions_from_file(test_file) + + # Should extract 2 functions, not the class method + assert len(functions) == 2 + assert "test_macro_function" in functions + assert "another_function" in functions + assert "method" not in functions # Class methods should be excluded + + # Check function details + test_func = functions["test_macro_function"] + assert test_func["line_number"] == 2 # First function starts at line 2 + assert "A test macro function" in test_func["docstring"] + + def test_extract_functions_from_empty_file(self, macro_tree, temp_macro_files): + """Test extracting functions from a file with no functions.""" + empty_file = temp_macro_files / "empty.py" + functions = macro_tree._extract_functions_from_file(empty_file) + + assert len(functions) == 0 + + def test_extract_functions_from_invalid_file(self, macro_tree): + """Test extracting functions from a non-existent file.""" + nonexistent_file = Path("/nonexistent/file.py") + functions = macro_tree._extract_functions_from_file(nonexistent_file) + + assert len(functions) == 0 + + def test_extract_functions_from_syntax_error_file(self, macro_tree, temp_macro_files): + """Test extracting functions from a file with syntax errors.""" + error_file = temp_macro_files / "error_file.py" + functions = macro_tree._extract_functions_from_file(error_file) + + # Should return empty dict on syntax error + assert len(functions) == 0 + + def test_create_file_item(self, macro_tree, temp_macro_files): + """Test creating a file item from a Python file.""" + test_file = temp_macro_files / "test_macros.py" + file_item = macro_tree._create_file_item(test_file) + + assert file_item is not None + assert file_item.text() == "test_macros" + assert file_item.rowCount() == 2 # Should have 2 function children + + # Check file data + file_data = file_item.data(Qt.ItemDataRole.UserRole) + assert file_data["type"] == "file" + assert file_data["file_path"] == str(test_file) + + # Check function children + func_names = [] + for row in range(file_item.rowCount()): + child = file_item.child(row) + func_names.append(child.text()) + + # Check function data + func_data = child.data(Qt.ItemDataRole.UserRole) + assert func_data["type"] == "function" + assert func_data["file_path"] == str(test_file) + assert "function_name" in func_data + assert "line_number" in func_data + + assert "test_macro_function" in func_names + assert "another_function" in func_names + + def test_create_file_item_with_private_file(self, macro_tree, temp_macro_files): + """Test that files starting with underscore are ignored.""" + private_file = temp_macro_files / "_private.py" + file_item = macro_tree._create_file_item(private_file) + + assert file_item is None + + def test_create_file_item_with_no_functions(self, macro_tree, temp_macro_files): + """Test that files with no functions return None.""" + empty_file = temp_macro_files / "empty.py" + file_item = macro_tree._create_file_item(empty_file) + + assert file_item is None + + +class TestMacroTreeInteractions: + """Test macro tree widget interactions and signals.""" + + def test_item_click_on_function(self, macro_tree, qtbot): + """Test clicking on a function item.""" + # Set up signal spy + macro_selected_signals = [] + + def on_macro_selected(function_name, file_path): + macro_selected_signals.append((function_name, file_path)) + + macro_tree.macro_selected.connect(on_macro_selected) + + # Find a function item + file_item = macro_tree.model.item(0) # First file + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) # First function + func_index = func_item.index() + + # Simulate click + macro_tree._on_item_clicked(func_index) + + # Check signal was emitted + assert len(macro_selected_signals) == 1 + function_name, file_path = macro_selected_signals[0] + assert function_name is not None + assert file_path is not None + assert file_path.endswith(".py") + + def test_item_click_on_file(self, macro_tree, qtbot): + """Test clicking on a file item (should not emit signal).""" + # Set up signal spy + macro_selected_signals = [] + + def on_macro_selected(function_name, file_path): + macro_selected_signals.append((function_name, file_path)) + + macro_tree.macro_selected.connect(on_macro_selected) + + # Find a file item + file_item = macro_tree.model.item(0) + if file_item: + file_index = file_item.index() + + # Simulate click + macro_tree._on_item_clicked(file_index) + + # Should not emit signal for file items + assert len(macro_selected_signals) == 0 + + def test_item_double_click_on_function(self, macro_tree, qtbot): + """Test double-clicking on a function item.""" + # Set up signal spy + open_requested_signals = [] + + def on_macro_open_requested(function_name, file_path): + open_requested_signals.append((function_name, file_path)) + + macro_tree.macro_open_requested.connect(on_macro_open_requested) + + # Find a function item + file_item = macro_tree.model.item(0) + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) + func_index = func_item.index() + + # Simulate double-click + macro_tree._on_item_double_clicked(func_index) + + # Check signal was emitted + assert len(open_requested_signals) == 1 + function_name, file_path = open_requested_signals[0] + assert function_name is not None + assert file_path is not None + + def test_hover_events(self, macro_tree, qtbot): + """Test mouse hover events and action button visibility.""" + # Get the tree view and its viewport + tree_view = macro_tree.tree + viewport = tree_view.viewport() + + # Initially, no item should be hovered + assert not macro_tree.delegate.hovered_index.isValid() + + # Find a function item to hover over + file_item = macro_tree.model.item(0) + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) + func_index = func_item.index() + + # Get the position of the function item + rect = tree_view.visualRect(func_index) + pos = rect.center() + + # Simulate a mouse move event over the item + mouse_event = QMouseEvent( + QEvent.Type.MouseMove, + pos, + tree_view.mapToGlobal(pos), + Qt.MouseButton.NoButton, + Qt.MouseButton.NoButton, + Qt.KeyboardModifier.NoModifier, + ) + + # Send the event to the viewport + macro_tree.eventFilter(viewport, mouse_event) + qtbot.wait(100) + + # Now, the hover index should be set + assert macro_tree.delegate.hovered_index.isValid() + assert macro_tree.delegate.hovered_index == func_index + + # Simulate mouse leaving the viewport + leave_event = QEvent(QEvent.Type.Leave) + macro_tree.eventFilter(viewport, leave_event) + qtbot.wait(100) + + # After leaving, no item should be hovered + assert not macro_tree.delegate.hovered_index.isValid() + + def test_macro_open_action(self, macro_tree, qtbot): + """Test the macro open action functionality.""" + # Set up signal spy + open_requested_signals = [] + + def on_macro_open_requested(function_name, file_path): + open_requested_signals.append((function_name, file_path)) + + macro_tree.macro_open_requested.connect(on_macro_open_requested) + + # Find a function item and set it as hovered + file_item = macro_tree.model.item(0) + if file_item and file_item.rowCount() > 0: + func_item = file_item.child(0) + func_index = func_item.index() + + # Set the delegate's hovered index and current macro info + macro_tree.delegate.set_hovered_index(func_index) + func_data = func_item.data(Qt.ItemDataRole.UserRole) + macro_tree.delegate.current_macro_info = func_data + + # Trigger the open action + macro_tree._on_macro_open_requested() + + # Check signal was emitted + assert len(open_requested_signals) == 1 + function_name, file_path = open_requested_signals[0] + assert function_name is not None + assert file_path is not None + + +class TestMacroTreeRefresh: + """Test macro tree refresh functionality.""" + + def test_refresh(self, macro_tree, temp_macro_files): + """Test refreshing the entire tree.""" + # Get initial count + initial_count = macro_tree.model.rowCount() + + # Add a new macro file + new_file = temp_macro_files / "new_macros.py" + new_file.write_text( + ''' +def new_function(): + """A new function.""" + return "new" +''' + ) + + # Refresh the tree + macro_tree.refresh() + + # Should have one more file + assert macro_tree.model.rowCount() == initial_count + 1 + + def test_refresh_file_item(self, macro_tree, temp_macro_files): + """Test refreshing a single file item.""" + # Find the test_macros.py file + test_file_path = str(temp_macro_files / "test_macros.py") + + # Get initial function count + initial_functions = [] + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data and item_data.get("file_path") == test_file_path: + for child_row in range(item.rowCount()): + child = item.child(child_row) + initial_functions.append(child.text()) + break + + # Modify the file to add a new function + with open(test_file_path, "a") as f: + f.write( + ''' + +def newly_added_function(): + """A newly added function.""" + return "added" +''' + ) + + # Refresh just this file + macro_tree.refresh_file_item(test_file_path) + + # Check that the new function was added + updated_functions = [] + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data and item_data.get("file_path") == test_file_path: + for child_row in range(item.rowCount()): + child = item.child(child_row) + updated_functions.append(child.text()) + break + + # Should have the new function + assert len(updated_functions) == len(initial_functions) + 1 + assert "newly_added_function" in updated_functions + + def test_refresh_nonexistent_file(self, macro_tree): + """Test refreshing a non-existent file.""" + # Should handle gracefully without crashing + macro_tree.refresh_file_item("/nonexistent/file.py") + + # Tree should remain unchanged + assert macro_tree.model.rowCount() >= 0 # Just ensure it doesn't crash + + def test_expand_collapse_all(self, macro_tree, qtbot): + """Test expand/collapse all functionality.""" + # Initially should be expanded + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item: + # Items with children should be expanded after initial load + if item.rowCount() > 0: + assert macro_tree.tree.isExpanded(item.index()) + + # Collapse all + macro_tree.collapse_all() + qtbot.wait(50) + + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item and item.rowCount() > 0: + assert not macro_tree.tree.isExpanded(item.index()) + + # Expand all + macro_tree.expand_all() + qtbot.wait(50) + + for row in range(macro_tree.model.rowCount()): + item = macro_tree.model.item(row) + if item and item.rowCount() > 0: + assert macro_tree.tree.isExpanded(item.index()) + + +class TestMacroItemDelegate: + """Test the custom macro item delegate functionality.""" + + def test_delegate_action_management(self, qtbot): + """Test adding and clearing delegate actions.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + # Should have at least one default action (open) + assert len(widget.delegate.macro_actions) >= 1 + + # Add a custom action + custom_action = MaterialIconAction(icon_name="edit", tooltip="Edit", parent=widget) + widget.add_macro_action(custom_action.action) + + # Should have the additional action + assert len(widget.delegate.macro_actions) >= 2 + + # Clear actions + widget.clear_actions() + + # Should be empty + assert len(widget.delegate.macro_actions) == 0 + + def test_delegate_hover_index_management(self, qtbot): + """Test hover index management in the delegate.""" + widget = MacroTreeWidget() + qtbot.addWidget(widget) + + # Initially no hover + assert not widget.delegate.hovered_index.isValid() + + # Create a fake index + fake_index = widget.model.createIndex(0, 0) + + # Set hover + widget.delegate.set_hovered_index(fake_index) + assert widget.delegate.hovered_index == fake_index + + # Clear hover + widget.delegate.set_hovered_index(QModelIndex()) + assert not widget.delegate.hovered_index.isValid() diff --git a/tests/unit_tests/test_monaco_dock.py b/tests/unit_tests/test_monaco_dock.py new file mode 100644 index 000000000..0a1d6b88b --- /dev/null +++ b/tests/unit_tests/test_monaco_dock.py @@ -0,0 +1,425 @@ +import os +from typing import Generator +from unittest import mock + +import pytest +from qtpy.QtWidgets import QFileDialog, QMessageBox + +from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +from .client_mocks import mocked_client + + +@pytest.fixture +def monaco_dock(qtbot, mocked_client) -> Generator[MonacoDock, None, None]: + """Create a MonacoDock for testing.""" + # Mock the macros functionality + mocked_client.macros = mock.MagicMock() + mocked_client.macros._update_handler = mock.MagicMock() + mocked_client.macros._update_handler.get_macros_from_file.return_value = {} + mocked_client.macros._update_handler.get_existing_macros.return_value = {} + + widget = MonacoDock(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class TestFocusEditor: + def test_last_focused_editor_initial_none(self, monaco_dock: MonacoDock): + """Test that last_focused_editor is initially None.""" + assert monaco_dock.last_focused_editor is not None + + def test_set_last_focused_editor(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test setting last_focused_editor when an editor is focused.""" + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) # Wait for the editor to be fully set up + + assert monaco_dock.last_focused_editor is not None + + def test_last_focused_editor_updates_on_focus_change( + self, qtbot, monaco_dock: MonacoDock, tmpdir + ): + """Test that last_focused_editor updates when focus changes.""" + file1 = tmpdir.join("file1.py") + file1.write("print('File 1')") + file2 = tmpdir.join("file2.py") + file2.write("print('File 2')") + + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1 = monaco_dock.last_focused_editor + + monaco_dock.open_file(str(file2)) + qtbot.wait(300) + editor2 = monaco_dock.last_focused_editor + + assert editor1 != editor2 + assert editor2 is not None + + def test_opening_existing_file_updates_focus(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test that opening an already open file simply switches focus to it.""" + file1 = tmpdir.join("file1.py") + file1.write("print('File 1')") + file2 = tmpdir.join("file2.py") + file2.write("print('File 2')") + + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1 = monaco_dock.last_focused_editor + + monaco_dock.open_file(str(file2)) + qtbot.wait(300) + editor2 = monaco_dock.last_focused_editor + + # Re-open file1 + monaco_dock.open_file(str(file1)) + qtbot.wait(300) + editor1_again = monaco_dock.last_focused_editor + + assert editor1 == editor1_again + assert editor1 != editor2 + assert editor2 is not None + + +class TestSaveFiles: + def test_save_file_existing_file_no_macros(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving an existing file that is not a macro.""" + # Create a test file + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + # Open file in Monaco dock + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Modified content')") + qtbot.wait(100) + + # Verify the editor is marked as modified + assert editor_widget.modified + + # Save the file + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QFileDialog.getSaveFileName" + ) as mock_dialog: + mock_dialog.return_value = (str(file_path), "Python files (*.py)") + monaco_dock.save_file() + qtbot.wait(100) + + # Verify file was saved + saved_content = file_path.read() + assert saved_content == 'print("Modified content")\n' + + # Verify editor is no longer marked as modified + assert not editor_widget.modified + + def test_save_file_with_macros_scope(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving a file with macros scope updates macro handler.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + editor_widget.set_text("def modified_function(): pass") + qtbot.wait(100) + + # Mock macro validation to return True (valid) + with mock.patch.object(monaco_dock, "_validate_macros", return_value=True): + # Mock file dialog to avoid opening actual dialog (file already exists) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") # User cancels + # Save the file (should save to existing file, not open dialog) + monaco_dock.save_file() + qtbot.wait(100) + + # Verify macro update methods were called + monaco_dock.client.macros._update_handler.get_macros_from_file.assert_called_with( + str(file_path) + ) + monaco_dock.client.macros._update_handler.get_existing_macros.assert_called_with( + str(file_path) + ) + + def test_save_file_invalid_macro_content(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test saving a macro file with invalid content shows warning.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content to invalid macro + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("exec('print(hello)')") # Invalid macro content + qtbot.wait(100) + + # Mock QMessageBox to capture warning + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.warning" + ) as mock_warning: + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") + # Save the file + monaco_dock.save_file() + qtbot.wait(100) + + # Verify validation was called and warning was shown + mock_warning.assert_called_once() + + # Verify file was not saved (content should remain original) + saved_content = file_path.read() + assert saved_content == "def test_function(): pass" + + def test_save_file_as_new_file(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test Save As functionality creates a new file.""" + # Create initial content in editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('New file content')") + qtbot.wait(100) + + # Mock QFileDialog.getSaveFileName + new_file_path = str(tmpdir.join("new_file.py")) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (new_file_path, "Python files (*.py)") + + # Save as new file + monaco_dock.save_file(force_save_as=True) + qtbot.wait(100) + + # Verify new file was created + assert os.path.exists(new_file_path) + with open(new_file_path, "r", encoding="utf-8") as f: + content = f.read() + assert content == 'print("New file content")\n' + + # Verify editor is no longer marked as modified + assert not editor_widget.modified + + # Verify current_file was updated + assert editor_widget.current_file == new_file_path + + def test_save_file_as_adds_py_extension(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test Save As automatically adds .py extension if none provided.""" + # Create initial content in editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Test content')") + qtbot.wait(100) + + # Mock QFileDialog.getSaveFileName to return path without extension + file_path_no_ext = str(tmpdir.join("test_file")) + expected_path = file_path_no_ext + ".py" + + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (file_path_no_ext, "All files (*)") + + # Save as new file + monaco_dock.save_file(force_save_as=True) + qtbot.wait(100) + + # Verify file was created with .py extension + assert os.path.exists(expected_path) + assert editor_widget.current_file == expected_path + + def test_save_file_no_focused_editor(self, monaco_dock: MonacoDock): + """Test save_file handles case when no editor is focused.""" + # Set last_focused_editor to None + with mock.patch.object(monaco_dock.last_focused_editor, "widget", return_value=None): + # Attempt to save should not raise exception + monaco_dock.save_file() + + def test_save_file_emits_macro_file_updated_signal(self, qtbot, monaco_dock, tmpdir): + """Test that macro_file_updated signal is emitted when saving macro files.""" + # Create a test file + file_path = tmpdir.join("test_macro.py") + file_path.write("def test_function(): pass") + + # Open file in Monaco dock with macros scope + monaco_dock.open_file(str(file_path), scope="macros") + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + editor_widget.set_text("def modified_function(): pass") + qtbot.wait(100) + + # Connect signal to capture emission + signal_emitted = [] + monaco_dock.macro_file_updated.connect(lambda path: signal_emitted.append(path)) + + # Mock file dialog to avoid opening actual dialog (file already exists) + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "") + # Save the file + monaco_dock.save_file() + qtbot.wait(100) + + # Verify signal was emitted + assert len(signal_emitted) == 1 + assert signal_emitted[0] == str(file_path) + + def test_close_dock_asks_to_save_modified_file(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test that closing a modified file dock asks to save changes.""" + # Create a test file + file_path = tmpdir.join("test.py") + file_path.write("print('Hello, World!')") + + # Open file in Monaco dock + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + # Get the editor widget and modify content + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('Modified content')") + qtbot.wait(100) + + # Mock QMessageBox to simulate user clicking 'Save' + with mock.patch( + "bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.StandardButton.Yes + + # Mock QFileDialog.getSaveFileName + with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog: + mock_dialog.return_value = (str(file_path), "Python files (*.py)") + + # Close the dock; sadly, calling close() alone does not trigger the closeRequested signal + # It is only triggered if the mouse is on top of the tab close button, so we directly call the handler + monaco_dock._on_editor_close_requested( + monaco_dock.last_focused_editor, editor_widget + ) + qtbot.wait(100) + + # Verify file was saved + saved_content = file_path.read() + assert saved_content == 'print("Modified content")\n' + + +class TestSignatureHelp: + def test_signature_help_signal_emission(self, qtbot, monaco_dock: MonacoDock): + """Test that signature help signal is emitted correctly.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data + signature_data = { + "signatures": [ + { + "label": "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)", + "documentation": { + "value": "Print objects to the text stream file, separated by sep and followed by end." + }, + } + ], + "activeSignature": 0, + "activeParameter": 0, + } + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with correct markdown format + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)" in emitted_signature + assert "Print objects to the text stream file" in emitted_signature + + def test_signature_help_empty_signatures(self, qtbot, monaco_dock: MonacoDock): + """Test signature help with empty signatures.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data with no signatures + signature_data = {"signatures": []} + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify empty string was emitted + assert len(signature_emitted) == 1 + assert signature_emitted[0] == "" + + def test_signature_help_no_documentation(self, qtbot, monaco_dock: MonacoDock): + """Test signature help when documentation is missing.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data without documentation + signature_data = {"signatures": [{"label": "function_name(param)"}], "activeSignature": 0} + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with just the function signature + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "function_name(param)" in emitted_signature + + def test_signature_help_string_documentation(self, qtbot, monaco_dock: MonacoDock): + """Test signature help when documentation is a string instead of dict.""" + # Connect signal to capture emission + signature_emitted = [] + monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig)) + + # Create mock signature data with string documentation + signature_data = { + "signatures": [ + {"label": "function_name(param)", "documentation": "Simple string documentation"} + ], + "activeSignature": 0, + } + + # Trigger signature change + monaco_dock._on_signature_change(signature_data) + qtbot.wait(100) + + # Verify signal was emitted with correct format + assert len(signature_emitted) == 1 + emitted_signature = signature_emitted[0] + assert "```python" in emitted_signature + assert "function_name(param)" in emitted_signature + assert "Simple string documentation" in emitted_signature + + def test_signature_help_connected_to_editor(self, qtbot, monaco_dock: MonacoDock): + """Test that signature help is connected when creating new editors.""" + # Create a new editor + editor_dock = monaco_dock.add_editor() + editor_widget = editor_dock.widget() + + # Verify the signal connection exists by checking connected signals + # We do this by mocking the signal and verifying the connection + with mock.patch.object(monaco_dock, "_on_signature_change") as mock_handler: + # Simulate signature help trigger from the editor + editor_widget.editor.signature_help_triggered.emit({"signatures": []}) + qtbot.wait(100) + + # Verify the handler was called + mock_handler.assert_called_once() diff --git a/tests/unit_tests/test_monaco_editor.py b/tests/unit_tests/test_monaco_editor.py index 149f4d75a..3f1c24fc2 100644 --- a/tests/unit_tests/test_monaco_editor.py +++ b/tests/unit_tests/test_monaco_editor.py @@ -1,11 +1,20 @@ +from unittest import mock + import pytest +from bec_lib.endpoints import MessageEndpoints +from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget +from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + +from .client_mocks import mocked_client +from .test_scan_control import available_scans_message @pytest.fixture -def monaco_widget(qtbot): - widget = MonacoWidget() +def monaco_widget(qtbot, mocked_client): + widget = MonacoWidget(client=mocked_client) + mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget @@ -37,3 +46,75 @@ def test_monaco_widget_readonly(monaco_widget: MonacoWidget, qtbot): monaco_widget.set_text("Attempting to change text") qtbot.waitUntil(lambda: monaco_widget.get_text() == "Attempting to change text", timeout=1000) assert monaco_widget.get_text() == "Attempting to change text" + + +def test_monaco_widget_show_scan_control_dialog(monaco_widget: MonacoWidget, qtbot): + """ + Test that the MonacoWidget can show the scan control dialog. + """ + + with mock.patch.object(monaco_widget, "_run_dialog_and_insert_code") as mock_run_dialog: + monaco_widget._show_scan_control_dialog() + mock_run_dialog.assert_called_once() + + +def test_monaco_widget_get_scan_control_code(monaco_widget: MonacoWidget, qtbot, mocked_client): + """ + Test that the MonacoWidget can get scan control code from the dialog. + """ + mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message) + + scan_control_dialog = ScanControlDialog(client=mocked_client) + qtbot.addWidget(scan_control_dialog) + qtbot.waitExposed(scan_control_dialog) + qtbot.wait(300) + + scan_control = scan_control_dialog.scan_control + scan_name = "grid_scan" + kwargs = {"exp_time": 0.2, "settling_time": 0.1, "relative": False, "burst_at_each_point": 2} + args_row1 = {"device": "samx", "start": -10, "stop": 10, "steps": 20} + args_row2 = {"device": "samy", "start": -5, "stop": 5, "steps": 10} + mock_slot = mock.MagicMock() + + scan_control.scan_args.connect(mock_slot) + + scan_control.comboBox_scan_selection.setCurrentText(scan_name) + + # Ensure there are two rows in the arg_box + current_rows = scan_control.arg_box.count_arg_rows() + required_rows = 2 + while current_rows < required_rows: + scan_control.arg_box.add_widget_bundle() + current_rows += 1 + + # Set kwargs in the UI + for kwarg_box in scan_control.kwarg_boxes: + for widget in kwarg_box.widgets: + if widget.arg_name in kwargs: + WidgetIO.set_value(widget, kwargs[widget.arg_name]) + + # Set args in the UI for both rows + arg_widgets = scan_control.arg_box.widgets # This is a flat list of widgets + num_columns = len(scan_control.arg_box.inputs) + num_rows = int(len(arg_widgets) / num_columns) + assert num_rows == required_rows # We expect 2 rows for grid_scan + + # Set values for first row + for i in range(num_columns): + widget = arg_widgets[i] + arg_name = widget.arg_name + if arg_name in args_row1: + WidgetIO.set_value(widget, args_row1[arg_name]) + + # Set values for second row + for i in range(num_columns): + widget = arg_widgets[num_columns + i] # Next row + arg_name = widget.arg_name + if arg_name in args_row2: + WidgetIO.set_value(widget, args_row2[arg_name]) + + scan_control_dialog.accept() + out = scan_control_dialog.get_scan_code() + + expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None)" + assert out == expected_code diff --git a/tests/unit_tests/test_tree_widget.py b/tests/unit_tests/test_tree_widget.py deleted file mode 100644 index 7a470cce5..000000000 --- a/tests/unit_tests/test_tree_widget.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest -from qtpy.QtWidgets import QTreeView, QWidget - -from bec_widgets.utils.colors import apply_theme - - -class DummyTree(QWidget): - def __init__(self): - super().__init__() - tree = QTreeView(self) - - -@pytest.fixture -def tree_widget(qtbot): - tree = DummyTree() - qtbot.addWidget(tree) - qtbot.waitExposed(tree) - yield tree - - -def test_tree_widget_init(tree_widget): - assert isinstance(tree_widget, QWidget) From 96e7ca34ab18496758c12668f6d0c196a9d6af32 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 30 Oct 2025 16:25:24 +0100 Subject: [PATCH 111/161] fix: add metadata to scan control export --- .../widgets/control/scan_control/scan_control.py | 3 ++- .../widgets/editors/monaco/scan_control_dialog.py | 2 +- tests/unit_tests/test_monaco_editor.py | 2 +- tests/unit_tests/test_scan_control.py | 10 ++++++++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 8948e4ef5..6bbef6e08 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -467,6 +467,8 @@ def get_scan_parameters(self, bec_object: bool = True): for box in self.kwarg_boxes: box_kwargs = box.get_parameters(bec_object) kwargs.update(box_kwargs) + if self._scan_metadata is not None: + kwargs["metadata"] = self._scan_metadata return args, kwargs def restore_scan_parameters(self, scan_name: str): @@ -519,7 +521,6 @@ def update_scan_metadata(self, md: dict | None): def run_scan(self): """Starts the selected scan with the given parameters.""" args, kwargs = self.get_scan_parameters() - kwargs["metadata"] = self._scan_metadata self.scan_args.emit(args) scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText()) if callable(scan_function): diff --git a/bec_widgets/widgets/editors/monaco/scan_control_dialog.py b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py index 2cbb7121d..f77e62c55 100644 --- a/bec_widgets/widgets/editors/monaco/scan_control_dialog.py +++ b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py @@ -89,7 +89,7 @@ def _generate_scan_code(self): # Add keyword arguments (excluding metadata) if processed_kwargs: - kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items() if k != "metadata"] + kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items()] all_args.extend(kwargs_strs) # Join all arguments and create the scan call diff --git a/tests/unit_tests/test_monaco_editor.py b/tests/unit_tests/test_monaco_editor.py index 3f1c24fc2..f0b39506b 100644 --- a/tests/unit_tests/test_monaco_editor.py +++ b/tests/unit_tests/test_monaco_editor.py @@ -116,5 +116,5 @@ def test_monaco_widget_get_scan_control_code(monaco_widget: MonacoWidget, qtbot, scan_control_dialog.accept() out = scan_control_dialog.get_scan_code() - expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None)" + expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None, metadata={'sample_name': ''})" assert out == expected_code diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index 30ce317d5..75f48af15 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -505,7 +505,13 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client): args, kwargs = scan_control.get_scan_parameters(bec_object=False) assert args == ["samx", 0.0, 2.0] - assert kwargs == {"steps": 10, "relative": False, "exp_time": 2.0, "burst_at_each_point": 1} + assert kwargs == { + "steps": 10, + "relative": False, + "exp_time": 2.0, + "burst_at_each_point": 1, + "metadata": {"sample_name": ""}, + } TEST_MD = {"sample_name": "Test Sample", "test key 1": "test value 1", "test key 2": "test value 2"} @@ -557,7 +563,7 @@ def test_scan_metadata_is_passed_to_scan_function(scan_control: ScanControl): scans = SimpleNamespace(grid_scan=MagicMock()) with ( patch.object(scan_control, "scans", scans), - patch.object(scan_control, "get_scan_parameters", lambda: ((), {})), + patch.object(scan_control, "get_scan_parameters", lambda: ((), {"metadata": TEST_MD})), ): scan_control.run_scan() scans.grid_scan.assert_called_once_with(metadata=TEST_MD) From 4284d572ec2ddb8e4491aa72b167e020e5297af3 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 30 Oct 2025 16:28:48 +0100 Subject: [PATCH 112/161] docs(monaco_dock): add missing argument --- bec_widgets/widgets/editors/monaco/monaco_dock.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py index eaee66694..25e8392a7 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_dock.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -295,6 +295,7 @@ def save_file( Args: widget (MonacoWidget | None): The widget to save. If None, the last focused editor will be used. force_save_as (bool): If True, the "Save As" dialog will be shown even if the file is already saved. + format_on_save (bool): If True, format the code before saving if it's a Python file. """ if widget is None: widget = self.last_focused_editor.widget() if self.last_focused_editor else None From e1e0fbd39023048076bda76d94e757f615314def Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 27 Oct 2025 22:04:51 +0100 Subject: [PATCH 113/161] feat(guided_tour): add guided tour --- bec_widgets/utils/guided_tour.py | 558 ++++++++++++++++++++++++++ bec_widgets/utils/toolbars/actions.py | 72 ++++ tests/unit_tests/test_guided_tour.py | 376 +++++++++++++++++ 3 files changed, 1006 insertions(+) create mode 100644 bec_widgets/utils/guided_tour.py create mode 100644 tests/unit_tests/test_guided_tour.py diff --git a/bec_widgets/utils/guided_tour.py b/bec_widgets/utils/guided_tour.py new file mode 100644 index 000000000..2b89bc9e1 --- /dev/null +++ b/bec_widgets/utils/guided_tour.py @@ -0,0 +1,558 @@ +"""Module providing a guided help system for creating interactive GUI tours.""" + +from __future__ import annotations + +import sys +import weakref +from typing import Callable, Dict, List, Optional, TypedDict +from uuid import uuid4 + +import louie +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from louie import saferef +from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal +from qtpy.QtGui import QColor, QPainter, QPen +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger + + +class TourStep(TypedDict): + """Type definition for a tour step.""" + + widget_ref: ( + louie.saferef.BoundMethodWeakref + | weakref.ReferenceType[QWidget | Callable[[], tuple[QWidget, str | None]]] + | Callable[[], tuple[QWidget, str | None]] + | None + ) + text: str + title: str + + +class TutorialOverlay(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + # Keep mouse events enabled for the overlay but we'll handle them manually + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) + self.current_rect = QRect() + self.message_box = self._create_message_box() + self.message_box.hide() + + def _create_message_box(self): + box = QFrame(self) + app = QApplication.instance() + bg_color = app.palette().window().color() + box.setStyleSheet( + f""" + QFrame {{ + background-color: {bg_color.name()}; + border-radius: 8px; + padding: 8px; + }} + """ + ) + layout = QVBoxLayout(box) + + # Top layout with close button (left) and step indicator (right) + top_layout = QHBoxLayout() + + # Close button on the left with material icon + self.close_btn = QPushButton() + self.close_btn.setIcon(material_icon("close")) + self.close_btn.setToolTip("Close") + self.close_btn.setMaximumSize(32, 32) + + # Step indicator on the right + self.step_label = QLabel() + self.step_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.step_label.setStyleSheet("color: #666; font-size: 12px; font-weight: bold;") + + top_layout.addWidget(self.close_btn) + top_layout.addStretch() + top_layout.addWidget(self.step_label) + + # Main content label + self.label = QLabel() + self.label.setWordWrap(True) + + # Bottom navigation buttons + btn_layout = QHBoxLayout() + + # Back button with material icon + self.back_btn = QPushButton("Back") + self.back_btn.setIcon(material_icon("arrow_back")) + + # Next button with material icon + self.next_btn = QPushButton("Next") + self.next_btn.setIcon(material_icon("arrow_forward")) + + btn_layout.addStretch() + btn_layout.addWidget(self.back_btn) + btn_layout.addWidget(self.next_btn) + + layout.addLayout(top_layout) + layout.addWidget(self.label) + layout.addLayout(btn_layout) + return box + + def paintEvent(self, event): # pylint: disable=unused-argument + if not self.current_rect.isValid(): + return + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Create semi-transparent overlay color + overlay_color = QColor(0, 0, 0, 160) + # Use exclusive coordinates to avoid 1px gaps caused by QRect.bottom()/right() being inclusive. + r = self.current_rect + rect_x, rect_y, rect_w, rect_h = r.x(), r.y(), r.width(), r.height() + + # Paint overlay in 4 regions around the highlighted widget using exclusive bounds + # Top region (everything above the highlight) + if rect_y > 0: + top_rect = QRect(0, 0, self.width(), rect_y) + painter.fillRect(top_rect, overlay_color) + + # Bottom region (everything below the highlight) + bottom_y = rect_y + rect_h + if bottom_y < self.height(): + bottom_rect = QRect(0, bottom_y, self.width(), self.height() - bottom_y) + painter.fillRect(bottom_rect, overlay_color) + + # Left region (to the left of the highlight) + if rect_x > 0: + left_rect = QRect(0, rect_y, rect_x, rect_h) + painter.fillRect(left_rect, overlay_color) + + # Right region (to the right of the highlight) + right_x = rect_x + rect_w + if right_x < self.width(): + right_rect = QRect(right_x, rect_y, self.width() - right_x, rect_h) + painter.fillRect(right_rect, overlay_color) + + # Draw highlight border around the clear area. Expand slightly so border doesn't leave a hairline gap. + border_rect = QRect(rect_x - 2, rect_y - 2, rect_w + 4, rect_h + 4) + painter.setPen(QPen(QColor(76, 175, 80), 3)) # Green border + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(border_rect, 8, 8) + painter.end() + + def show_step( + self, rect: QRect, title: str, text: str, current_step: int = 1, total_steps: int = 1 + ): + """ + rect must already be in the overlay's coordinate space (i.e. mapped). + This method positions the message box so it does not overlap the rect. + """ + self.current_rect = rect + + # Update step indicator in top right + self.step_label.setText(f"Step {current_step} of {total_steps}") + + # Update main content text (without step number since it's now in top right) + content_text = f"{title}
{text}" if title else text + self.label.setText(content_text) + self.message_box.adjustSize() # ensure layout applied + message_size = self.message_box.size() # actual widget size (width, height) + + spacing = 15 + + # Preferred placement: to the right, vertically centered + pos_x = rect.right() + spacing + pos_y = rect.center().y() - (message_size.height() // 2) + + # If it would go off the right edge, try left of the widget + if pos_x + message_size.width() > self.width(): + pos_x = rect.left() - message_size.width() - spacing + # vertical center is still good, but if that overlaps top/bottom we'll clamp below + + # If it goes off the left edge (no space either side), place below, centered horizontally + if pos_x < spacing: + pos_x = rect.center().x() - (message_size.width() // 2) + pos_y = rect.bottom() + spacing + + # If it goes off the bottom, try moving it above the widget + if pos_y + message_size.height() > self.height() - spacing: + # if there's room above the rect, put it there + candidate_y = rect.top() - message_size.height() - spacing + if candidate_y >= spacing: + pos_y = candidate_y + else: + # otherwise clamp to bottom with spacing + pos_y = max(spacing, self.height() - message_size.height() - spacing) + + # If it goes off the top, clamp down + pos_y = max(spacing, pos_y) + + # Make sure we don't poke the left edge + pos_x = max(spacing, min(pos_x, self.width() - message_size.width() - spacing)) + + # Apply geometry and show + self.message_box.setGeometry( + int(pos_x), int(pos_y), message_size.width(), message_size.height() + ) + self.message_box.show() + self.update() + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Resize: + self.setGeometry(obj.rect()) + return False + + +class GuidedTour(QObject): + """ + A guided help system for creating interactive GUI tours. + + Allows developers to register widgets with help text and create guided tours. + """ + + tour_started = Signal() + tour_finished = Signal() + step_changed = Signal(int, int) # current_step, total_steps + + def __init__(self, main_window: QWidget): + super().__init__() + self.main_window_ref = saferef.safe_ref(main_window) + self.overlay = None + self._registered_widgets: Dict[str, TourStep] = {} + self._tour_steps: List[TourStep] = [] + self._current_index = 0 + self._active = False + + @property + def main_window(self) -> Optional[QWidget]: + """Get the main window from weak reference.""" + if self.main_window_ref and callable(self.main_window_ref): + widget = self.main_window_ref() + if isinstance(widget, QWidget): + return widget + return None + + def register_widget( + self, + *, + widget: QWidget | Callable[[], tuple[QWidget, str | None]], + text: str = "", + title: str = "", + ) -> str: + """ + Register a widget with help text for tours. + + Args: + widget (QWidget | Callable[[], tuple[QWidget, str | None]]): The target widget or a callable that returns the widget and its help text. + text (str): The help text for the widget. This will be shown during the tour. + title (str, optional): A title for the widget (defaults to its class name). + + Returns: + str: The unique ID for the registered widget. + """ + step_id = str(uuid4()) + + # Check if it is a bound method + if callable(widget) and not hasattr(widget, "__self__"): + # We are dealing with a plain callable + widget_ref = widget + else: + # Create weak reference for QWidget instances + widget_ref = saferef.safe_ref(widget) + + self._registered_widgets[step_id] = { + "widget_ref": widget_ref, + "text": text, + "title": title + or (widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"), + } + logger.debug(f"Registered widget {title} with ID {step_id}") + return step_id + + def unregister_widget(self, step_id: str) -> bool: + """ + Unregister a previously registered widget. + + Args: + step_id (str): The unique ID of the registered widget. + + Returns: + bool: True if the widget was unregistered, False if not found. + """ + if self._active: + raise RuntimeError("Cannot unregister widget while tour is active") + if step_id in self._registered_widgets: + if self._registered_widgets[step_id] in self._tour_steps: + self._tour_steps.remove(self._registered_widgets[step_id]) + del self._registered_widgets[step_id] + return True + return False + + def create_tour(self, step_ids: List[str] | None = None) -> bool: + """ + Create a tour from registered widget IDs. + + Args: + step_ids (List[str], optional): List of registered widget IDs to include in the tour. If None, all registered widgets will be included. + """ + if step_ids is None: + step_ids = list(self._registered_widgets.keys()) + + tour_steps = [] + for step_id in step_ids: + if step_id not in self._registered_widgets: + logger.error(f"Step ID {step_id} not found") + return False + tour_steps.append(self._registered_widgets[step_id]) + + self._tour_steps = tour_steps + logger.info(f"Created tour with {len(tour_steps)} steps") + return True + + @SafeSlot() + def start_tour(self): + """Start the guided tour.""" + if not self._tour_steps: + self.create_tour() + + if self._active: + logger.warning("Tour already active") + return + + main_window = self.main_window + if main_window is None: + logger.error("Main window no longer exists (weak reference is dead)") + return + + self._active = True + self._current_index = 0 + + # Create overlay + self.overlay = TutorialOverlay(main_window) + self.overlay.setGeometry(main_window.rect()) + self.overlay.show() + main_window.installEventFilter(self.overlay) + + # Connect signals + self.overlay.next_btn.clicked.connect(self.next_step) + self.overlay.back_btn.clicked.connect(self.prev_step) + self.overlay.close_btn.clicked.connect(self.stop_tour) + + main_window.installEventFilter(self) + self._show_current_step() + self.tour_started.emit() + logger.info("Started guided tour") + + @SafeSlot() + def stop_tour(self): + """Stop the current tour.""" + if not self._active: + return + + self._active = False + + main_window = self.main_window + if self.overlay and main_window: + main_window.removeEventFilter(self.overlay) + self.overlay.hide() + self.overlay.deleteLater() + self.overlay = None + + if main_window: + main_window.removeEventFilter(self) + self.tour_finished.emit() + logger.info("Stopped guided tour") + + @SafeSlot() + def next_step(self): + """Move to next step or finish tour if on last step.""" + if not self._active: + return + + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + # On last step, finish the tour + self.stop_tour() + + @SafeSlot() + def prev_step(self): + """Move to previous step.""" + if not self._active: + return + + if self._current_index > 0: + self._current_index -= 1 + self._show_current_step() + + def _show_current_step(self): + """Display the current step.""" + if not self._active or not self.overlay: + return + + step = self._tour_steps[self._current_index] + widget_ref = step.get("widget_ref") + + step_title = step["title"] + widget = None + step_text = step["text"] + + # Resolve weak reference + if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)): + widget = widget_ref() + else: + widget = widget_ref + + # If the widget does not exist, log warning and skip to next step or stop tour + if not widget: + logger.warning( + f"Widget for step {step['title']} no longer exists (weak reference is dead)" + ) + # Skip to next step or stop tour if this was the last step + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + self.stop_tour() + return + + # If the user provided a callable, resolve it to get the widget and optional alt text + if callable(widget): + widget, alt_text = widget() + if alt_text: + step_text = alt_text + + if not widget.isVisible(): + logger.warning(f"Widget for step {step['title']} is not visible") + return + + # Map widget coordinates to overlay coordinates + main_window = self.main_window + if main_window is None: + logger.error("Main window no longer exists (weak reference is dead)") + self.stop_tour() + return + + rect = widget.rect() + top_left = widget.mapTo(main_window, rect.topLeft()) + global_rect = QRect(top_left, rect.size()) + + # Calculate step numbers + current_step = self._current_index + 1 + total_steps = len(self._tour_steps) + + self.overlay.show_step(global_rect, step_title, step_text, current_step, total_steps) + + # Update button states + self.overlay.back_btn.setEnabled(self._current_index > 0) + + # Update next button text and state + is_last_step = self._current_index >= len(self._tour_steps) - 1 + if is_last_step: + self.overlay.next_btn.setText("Finish") + self.overlay.next_btn.setIcon(material_icon("check")) + self.overlay.next_btn.setEnabled(True) + else: + self.overlay.next_btn.setText("Next") + self.overlay.next_btn.setIcon(material_icon("arrow_forward")) + self.overlay.next_btn.setEnabled(True) + + self.step_changed.emit(self._current_index + 1, len(self._tour_steps)) + + def get_registered_widgets(self) -> Dict[str, TourStep]: + """Get all registered widgets.""" + return self._registered_widgets.copy() + + def clear_registrations(self): + """Clear all registered widgets.""" + if self._active: + self.stop_tour() + self._registered_widgets.clear() + self._tour_steps.clear() + logger.info("Cleared all registrations") + + def eventFilter(self, obj, event): + """Handle window resize/move events to update step positioning.""" + if event.type() in (QEvent.Type.Move, QEvent.Type.Resize): + if self._active: + self._show_current_step() + return super().eventFilter(obj, event) + + +################################################################################ +############ # Example usage of GuidedTour system ############################## +################################################################################ + + +class MainWindow(QMainWindow): # pragma: no cover + def __init__(self): + super().__init__() + self.setWindowTitle("Guided Help Demo") + central = QWidget() + layout = QVBoxLayout(central) + + # Create demo widgets + self.btn1 = QPushButton("Button 1") + self.btn2 = QPushButton("Button 2") + self.start_tour_btn = QPushButton("Start Guided Tour") + + layout.addWidget(QLabel("Welcome to the Guided Help Demo!")) + layout.addWidget(self.btn1) + layout.addWidget(self.btn2) + layout.addWidget(self.start_tour_btn) + layout.addStretch() + self.setCentralWidget(central) + + # Create guided help system + self.guided_help = GuidedTour(self) + + # Register widgets with help text + self.guided_help.register_widget( + widget=self.btn1, + text="This is the first button. It demonstrates how to highlight a widget and show help text next to it.", + title="First Button", + ) + + def widget_with_alt_text(): + import numpy as np + + if np.random.randint(0, 10) % 2 == 0: + return (self.btn2, None) + return (self.btn2, "This is an alternative help text for Button 2.") + + self.guided_help.register_widget( + widget=widget_with_alt_text, + text="This is the second button. Notice how the help tooltip is positioned smartly to stay visible.", + title="Second Button", + ) + + # Create tour from registered widgets + widget_ids = list(self.guided_help.get_registered_widgets().keys()) + self.guided_help.create_tour(widget_ids) + + # Connect start button + self.start_tour_btn.clicked.connect(self.guided_help.start_tour) + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + from bec_qthemes import apply_theme + + apply_theme("dark") + w = MainWindow() + w.resize(400, 300) + w.show() + sys.exit(app.exec()) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index dbeb937c3..3ac67fc09 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -25,6 +25,7 @@ ) import bec_widgets +from bec_widgets.utils.guided_tour import GuidedTour from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox @@ -587,3 +588,74 @@ def cleanup(self): self.combobox.close() self.combobox.deleteLater() return super().cleanup() + + +class TutorialAction(MaterialIconAction): + """ + Action for starting a guided tutorial/help tour. + + This action automatically initializes a GuidedTour instance and provides + methods to register widgets and start tours. + + Args: + main_window (QWidget): The main window widget for the guided tour overlay. + tooltip (str, optional): The tooltip for the action. Defaults to "Start Guided Tutorial". + parent (QWidget or None, optional): Parent widget for the underlying QAction. + """ + + def __init__(self, main_window: QWidget, tooltip: str = "Start Guided Tutorial", parent=None): + super().__init__( + icon_name="help", + tooltip=tooltip, + checkable=False, + filled=False, + color=None, + parent=parent, + ) + + self.guided_help = GuidedTour(main_window) + self.main_window = main_window + + # Connect the action to start the tour + self.action.triggered.connect(self.start_tour) + + def register_widget(self, widget: QWidget, text: str, widget_name: str = "") -> str: + """ + Register a widget for the guided tour. + + Args: + widget (QWidget): The widget to highlight during the tour. + text (str): The help text to display. + widget_name (str, optional): Optional name for the widget. + + Returns: + str: Unique ID for the registered widget. + """ + return self.guided_help.register_widget(widget, text, widget_name) + + def start_tour(self): + """Start the guided tour with all registered widgets.""" + registered_widgets = self.guided_help.get_registered_widgets() + if registered_widgets: + # Create tour from all registered widgets + step_ids = list(registered_widgets.keys()) + if self.guided_help.create_tour(step_ids): + self.guided_help.start_tour() + else: + logger.warning("Failed to create guided tour") + else: + logger.warning("No widgets registered for guided tour") + + def has_registered_widgets(self) -> bool: + """Check if any widgets have been registered for the tour.""" + return len(self.guided_help.get_registered_widgets()) > 0 + + def clear_registered_widgets(self): + """Clear all registered widgets.""" + self.guided_help.clear_registrations() + + def cleanup(self): + """Clean up the guided help instance.""" + if hasattr(self, "guided_help"): + self.guided_help.stop_tour() + super().cleanup() diff --git a/tests/unit_tests/test_guided_tour.py b/tests/unit_tests/test_guided_tour.py new file mode 100644 index 000000000..bb3f7a79d --- /dev/null +++ b/tests/unit_tests/test_guided_tour.py @@ -0,0 +1,376 @@ +from unittest import mock + +import pytest +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.guided_tour import GuidedTour + + +@pytest.fixture +def main_window(qtbot): + """Create a main window for testing.""" + window = QWidget() + window.resize(800, 600) + qtbot.addWidget(window) + return window + + +@pytest.fixture +def guided_help(main_window): + """Create a GuidedTour instance for testing.""" + return GuidedTour(main_window) + + +@pytest.fixture +def test_widget(main_window): + """Create a test widget.""" + widget = QWidget(main_window) + widget.resize(100, 50) + widget.show() + return widget + + +class DummyWidget(QWidget): + """A dummy widget for testing purposes.""" + + def isVisible(self) -> bool: + """Override isVisible to always return True for testing.""" + return True + + +class TestGuidedTour: + """Test the GuidedTour class core functionality.""" + + def test_initialization(self, guided_help): + """Test GuidedTour is properly initialized.""" + assert guided_help.main_window is not None + assert guided_help._registered_widgets == {} + assert guided_help._tour_steps == [] + assert guided_help._current_index == 0 + assert guided_help._active is False + + def test_register_widget(self, guided_help: GuidedTour, test_widget: QWidget): + """Test widget registration creates weak references.""" + widget_id = guided_help.register_widget( + widget=test_widget, text="Test widget", title="TestWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Test widget" + assert registered["title"] == "TestWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]() is test_widget + + def test_register_widget_auto_name(self, guided_help: GuidedTour, test_widget: QWidget): + """Test widget registration with automatic naming.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + registered = guided_help._registered_widgets[widget_id] + assert registered["title"] == "QWidget" + + def test_create_tour_valid_ids(self, guided_help: GuidedTour, test_widget: QWidget): + """Test creating tour with valid widget IDs.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + result = guided_help.create_tour([widget_id]) + + assert result is True + assert len(guided_help._tour_steps) == 1 + assert guided_help._tour_steps[0]["text"] == "Test widget" + + def test_create_tour_invalid_ids(self, guided_help: GuidedTour): + """Test creating tour with invalid widget IDs.""" + result = guided_help.create_tour(["invalid_id"]) + + assert result is False + assert len(guided_help._tour_steps) == 0 + + def test_start_tour_no_steps(self, guided_help: GuidedTour, test_widget: QWidget): + """Test starting tour with no steps will add all registered widgets.""" + # Register a widget + guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + assert len(guided_help._tour_steps) == 1 + + def test_start_tour_success(self, guided_help: GuidedTour, test_widget: QWidget): + """Test successful tour start.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.create_tour([widget_id]) + + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + assert guided_help.overlay is not None + + def test_stop_tour(self, guided_help: GuidedTour, test_widget: QWidget): + """Test stopping a tour.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + guided_help.stop_tour() + + assert guided_help._active is False + + def test_next_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test moving to next step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1") + guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2") + + guided_help.start_tour() + + assert guided_help._current_index == 0 + + guided_help.next_step() + + assert guided_help._current_index == 1 + + def test_next_step_finish_tour(self, guided_help: GuidedTour, test_widget: QWidget): + """Test next step on last step finishes tour.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + guided_help.next_step() + + assert guided_help._active is False + + def test_prev_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test moving to previous step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="Step 1", title="Widget1") + guided_help.register_widget(widget=widget2, text="Step 2", title="Widget2") + + guided_help.start_tour() + guided_help.next_step() + + assert guided_help._current_index == 1 + + guided_help.prev_step() + + assert guided_help._current_index == 0 + + def test_get_registered_widgets(self, guided_help: GuidedTour, test_widget: QWidget): + """Test getting registered widgets.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + registered = guided_help.get_registered_widgets() + + assert widget_id in registered + assert registered[widget_id]["text"] == "Test widget" + + def test_clear_registrations(self, guided_help: GuidedTour, test_widget: QWidget): + """Test clearing all registrations.""" + guided_help.register_widget(widget=test_widget, text="Test widget") + + guided_help.clear_registrations() + + assert len(guided_help._registered_widgets) == 0 + assert len(guided_help._tour_steps) == 0 + + def test_weak_reference_main_window(self, main_window: QWidget): + """Test that main window is stored as weak reference.""" + guided_help = GuidedTour(main_window) + + # Should be able to get main window through weak reference + assert guided_help.main_window is not None + assert guided_help.main_window == main_window + + def test_complete_tour_flow(self, guided_help: GuidedTour, test_widget: QWidget): + """Test complete tour workflow.""" + # Create widgets + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + # Register widgets + id1 = guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + id2 = guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + + # Create and start tour + guided_help.start_tour() + + assert guided_help._active is True + assert guided_help._current_index == 0 + + # Move through tour + guided_help.next_step() + assert guided_help._current_index == 1 + + # Finish tour + guided_help.next_step() + assert guided_help._active is False + + def test_finish_button_on_last_step(self, guided_help: GuidedTour, test_widget: QWidget): + """Test that the Next button changes to Finish on the last step.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + guided_help.start_tour() + + overlay = guided_help.overlay + assert overlay is not None + + # First step should show "Next" + assert "Next" in overlay.next_btn.text() + + # Navigate to last step + guided_help.next_step() + + # Last step should show "Finish" + assert "Finish" in overlay.next_btn.text() + + def test_step_counter_display(self, guided_help: GuidedTour, test_widget: QWidget): + """Test that step counter is properly displayed.""" + widget1 = DummyWidget(test_widget) + widget2 = DummyWidget(test_widget) + + guided_help.register_widget(widget=widget1, text="First widget", title="Widget1") + guided_help.register_widget(widget=widget2, text="Second widget", title="Widget2") + + guided_help.start_tour() + + overlay = guided_help.overlay + assert overlay is not None + assert overlay.step_label.text() == "Step 1 of 2" + + @mock.patch("bec_widgets.utils.guided_tour.logger") + def test_error_handling(self, mock_logger, guided_help): + """Test error handling and logging.""" + # Test with invalid step ID + result = guided_help.create_tour(["invalid_id"]) + assert result is False + mock_logger.error.assert_called() + + def test_memory_safety_widget_deletion(self, guided_help: GuidedTour, test_widget: QWidget): + """Test memory safety when widget is deleted.""" + widget = QWidget(test_widget) + + # Register widget + widget_id = guided_help.register_widget(widget=widget, text="Test widget") + + # Verify weak reference works + registered = guided_help._registered_widgets[widget_id] + assert registered["widget_ref"]() is widget + + # Delete widget + widget.close() + widget.setParent(None) + del widget + + # The weak reference should now return None + # This tests that our weak reference implementation is working + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["widget_ref"]() is None + + def test_unregister_widget(self, guided_help: GuidedTour, test_widget: QWidget): + """Test unregistering a widget.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + + # Unregister the widget + guided_help.unregister_widget(widget_id) + + assert widget_id not in guided_help._registered_widgets + + def test_unregister_nonexistent_widget(self, guided_help: GuidedTour): + """Test unregistering a widget that does not exist.""" + # Should not raise an error + assert guided_help.unregister_widget("nonexistent_id") is False + + def test_unregister_widget_removes_from_tour( + self, guided_help: GuidedTour, test_widget: QWidget + ): + """Test that unregistering a widget also removes it from the tour steps.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.create_tour([widget_id]) + + # Unregister the widget + guided_help.unregister_widget(widget_id) + + # The tour steps should no longer contain the unregistered widget + assert len(guided_help._tour_steps) == 0 + + def test_unregister_widget_during_tour_raises( + self, guided_help: GuidedTour, test_widget: QWidget + ): + """Test that unregistering a widget during an active tour raises an error.""" + widget_id = guided_help.register_widget(widget=test_widget, text="Test widget") + guided_help.start_tour() + + with pytest.raises(RuntimeError): + guided_help.unregister_widget(widget_id) + + def test_register_lambda_function(self, guided_help: GuidedTour, test_widget: QWidget): + """Test registering a lambda function as a widget.""" + widget_id = guided_help.register_widget( + widget=lambda: (test_widget, "test text"), text="Lambda widget", title="LambdaWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Lambda widget" + assert registered["title"] == "LambdaWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]()[0] is test_widget + assert registered["widget_ref"]()[1] == "test text" + + def test_register_widget_local_function(self, guided_help: GuidedTour, test_widget: QWidget): + """Test registering a local function as a widget.""" + + def local_widget_function(): + return test_widget, "local text" + + widget_id = guided_help.register_widget( + widget=local_widget_function, text="Local function widget", title="LocalWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == "Local function widget" + assert registered["title"] == "LocalWidget" + # Check that widget_ref is callable (weak reference) + assert callable(registered["widget_ref"]) + # Check that we can dereference the weak reference + assert registered["widget_ref"]()[0] is test_widget + assert registered["widget_ref"]()[1] == "local text" + + def test_text_accepts_html_content(self, guided_help: GuidedTour, test_widget: QWidget, qtbot): + """Test that registered text can contain HTML content.""" + html_text = ( + "Bold Text with Italics and a
link." + ) + widget_id = guided_help.register_widget( + widget=test_widget, text=html_text, title="HTMLWidget" + ) + + assert widget_id in guided_help._registered_widgets + registered = guided_help._registered_widgets[widget_id] + assert registered["text"] == html_text + + def test_overlay_painter(self, guided_help: GuidedTour, test_widget: QWidget, qtbot): + """ + Test that the overlay painter works without errors. + While we cannot directly test the visual output, we can ensure + that calling the paintEvent does not raise exceptions. + """ + widget_id = guided_help.register_widget( + widget=test_widget, text="Test widget for overlay", title="OverlayWidget" + ) + widget = guided_help._registered_widgets[widget_id]["widget_ref"]() + with mock.patch.object(widget, "isVisible", return_value=True): + guided_help.start_tour() + guided_help.overlay.paintEvent(None) # Force paint event to render text + qtbot.wait(300) # Wait for rendering From fa5135d2d783d98e15ca0730e66a0f68f1ad540e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 31 Oct 2025 12:29:00 +0100 Subject: [PATCH 114/161] feat(guided_tour): added option to register QActions from toolbar --- bec_widgets/utils/guided_tour.py | 329 ++++++++++++++++++++------ bec_widgets/utils/toolbars/actions.py | 14 +- tests/unit_tests/test_guided_tour.py | 33 ++- 3 files changed, 297 insertions(+), 79 deletions(-) diff --git a/bec_widgets/utils/guided_tour.py b/bec_widgets/utils/guided_tour.py index 2b89bc9e1..4261c703b 100644 --- a/bec_widgets/utils/guided_tour.py +++ b/bec_widgets/utils/guided_tour.py @@ -4,7 +4,7 @@ import sys import weakref -from typing import Callable, Dict, List, Optional, TypedDict +from typing import Callable, Dict, List, TypedDict from uuid import uuid4 import louie @@ -12,19 +12,24 @@ from bec_qthemes import material_icon from louie import saferef from qtpy.QtCore import QEvent, QObject, QRect, Qt, Signal -from qtpy.QtGui import QColor, QPainter, QPen +from qtpy.QtGui import QAction, QColor, QPainter, QPen from qtpy.QtWidgets import ( QApplication, QFrame, QHBoxLayout, QLabel, QMainWindow, + QMenuBar, QPushButton, + QToolBar, QVBoxLayout, QWidget, ) from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar logger = bec_logger.logger @@ -34,8 +39,10 @@ class TourStep(TypedDict): widget_ref: ( louie.saferef.BoundMethodWeakref - | weakref.ReferenceType[QWidget | Callable[[], tuple[QWidget, str | None]]] - | Callable[[], tuple[QWidget, str | None]] + | weakref.ReferenceType[ + QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]] + ] + | Callable[[], tuple[QWidget | QAction, str | None]] | None ) text: str @@ -158,6 +165,13 @@ def show_step( """ rect must already be in the overlay's coordinate space (i.e. mapped). This method positions the message box so it does not overlap the rect. + + Args: + rect(QRect): rectangle to highlight + title(str): Title text for the step + text(str): Main content text for the step + current_step(int): Current step number + total_steps(int): Total number of steps in the tour """ self.current_rect = rect @@ -226,8 +240,9 @@ class GuidedTour(QObject): tour_finished = Signal() step_changed = Signal(int, int) # current_step, total_steps - def __init__(self, main_window: QWidget): + def __init__(self, main_window: QWidget, *, enforce_visibility: bool = True): super().__init__() + self._visible_check: bool = enforce_visibility self.main_window_ref = saferef.safe_ref(main_window) self.overlay = None self._registered_widgets: Dict[str, TourStep] = {} @@ -236,7 +251,7 @@ def __init__(self, main_window: QWidget): self._active = False @property - def main_window(self) -> Optional[QWidget]: + def main_window(self) -> QWidget | None: """Get the main window from weak reference.""" if self.main_window_ref and callable(self.main_window_ref): widget = self.main_window_ref() @@ -247,7 +262,7 @@ def main_window(self) -> Optional[QWidget]: def register_widget( self, *, - widget: QWidget | Callable[[], tuple[QWidget, str | None]], + widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]], text: str = "", title: str = "", ) -> str: @@ -255,32 +270,70 @@ def register_widget( Register a widget with help text for tours. Args: - widget (QWidget | Callable[[], tuple[QWidget, str | None]]): The target widget or a callable that returns the widget and its help text. + widget (QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]]): The target widget or a callable that returns the widget and its help text. text (str): The help text for the widget. This will be shown during the tour. - title (str, optional): A title for the widget (defaults to its class name). + title (str, optional): A title for the widget (defaults to its class name or action text). Returns: str: The unique ID for the registered widget. """ step_id = str(uuid4()) - - # Check if it is a bound method + # If it's a plain callable if callable(widget) and not hasattr(widget, "__self__"): - # We are dealing with a plain callable widget_ref = widget + default_title = "Widget" + elif isinstance(widget, QAction): + widget_ref = weakref.ref(widget) + default_title = widget.text() or "Action" + elif hasattr(widget, "get_toolbar_button") and callable(widget.get_toolbar_button): + + def _resolve_toolbar_button(toolbar_action=widget): + button = toolbar_action.get_toolbar_button() + return (button, None) + + widget_ref = _resolve_toolbar_button + default_title = getattr(widget, "tooltip", "Toolbar Menu") else: - # Create weak reference for QWidget instances widget_ref = saferef.safe_ref(widget) + default_title = widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget" self._registered_widgets[step_id] = { "widget_ref": widget_ref, "text": text, - "title": title - or (widget.__class__.__name__ if hasattr(widget, "__class__") else "Widget"), + "title": title or default_title, } - logger.debug(f"Registered widget {title} with ID {step_id}") + logger.debug(f"Registered widget {title or default_title} with ID {step_id}") return step_id + def _action_highlight_rect(self, action: QAction) -> QRect | None: + """ + Try to find the QRect in main_window coordinates that should be highlighted for the given QAction. + Returns None if not found (e.g. not visible). + """ + mw = self.main_window + if mw is None: + return None + # Try toolbars first + for tb in mw.findChildren(QToolBar): + btn = tb.widgetForAction(action) + if btn and btn.isVisible(): + rect = btn.rect() + top_left = btn.mapTo(mw, rect.topLeft()) + return QRect(top_left, rect.size()) + # Try menu bars + menubars = [] + if hasattr(mw, "menuBar") and callable(getattr(mw, "menuBar", None)): + mb = mw.menuBar() + if mb and mb not in menubars: + menubars.append(mb) + menubars += [mb for mb in mw.findChildren(QMenuBar) if mb not in menubars] + for mb in menubars: + if action in mb.actions(): + ar = mb.actionGeometry(action) + top_left = mb.mapTo(mw, ar.topLeft()) + return QRect(top_left, ar.size()) + return None + def unregister_widget(self, step_id: str) -> bool: """ Unregister a previously registered widget. @@ -306,6 +359,9 @@ def create_tour(self, step_ids: List[str] | None = None) -> bool: Args: step_ids (List[str], optional): List of registered widget IDs to include in the tour. If None, all registered widgets will be included. + + Returns: + bool: True if the tour was created successfully, False if any step IDs were not found """ if step_ids is None: step_ids = list(self._registered_widgets.keys()) @@ -404,57 +460,28 @@ def _show_current_step(self): return step = self._tour_steps[self._current_index] - widget_ref = step.get("widget_ref") - step_title = step["title"] - widget = None - step_text = step["text"] - - # Resolve weak reference - if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)): - widget = widget_ref() - else: - widget = widget_ref - - # If the widget does not exist, log warning and skip to next step or stop tour - if not widget: - logger.warning( - f"Widget for step {step['title']} no longer exists (weak reference is dead)" - ) - # Skip to next step or stop tour if this was the last step - if self._current_index < len(self._tour_steps) - 1: - self._current_index += 1 - self._show_current_step() - else: - self.stop_tour() - return - - # If the user provided a callable, resolve it to get the widget and optional alt text - if callable(widget): - widget, alt_text = widget() - if alt_text: - step_text = alt_text - if not widget.isVisible(): - logger.warning(f"Widget for step {step['title']} is not visible") + target, step_text = self._resolve_step_target(step) + if target is None: + self._advance_past_invalid_step(step_title, reason="Step target no longer exists.") return - # Map widget coordinates to overlay coordinates main_window = self.main_window if main_window is None: logger.error("Main window no longer exists (weak reference is dead)") self.stop_tour() return - rect = widget.rect() - top_left = widget.mapTo(main_window, rect.topLeft()) - global_rect = QRect(top_left, rect.size()) + highlight_rect = self._get_highlight_rect(main_window, target, step_title) + if highlight_rect is None: + return # Calculate step numbers current_step = self._current_index + 1 total_steps = len(self._tour_steps) - self.overlay.show_step(global_rect, step_title, step_text, current_step, total_steps) + self.overlay.show_step(highlight_rect, step_title, step_text, current_step, total_steps) # Update button states self.overlay.back_btn.setEnabled(self._current_index > 0) @@ -472,6 +499,89 @@ def _show_current_step(self): self.step_changed.emit(self._current_index + 1, len(self._tour_steps)) + def _resolve_step_target(self, step: TourStep) -> tuple[QWidget | QAction | None, str]: + """ + Resolve the target widget/action for the given step. + + Args: + step(TourStep): The tour step to resolve. + + Returns: + tuple[QWidget | QAction | None, str]: The resolved target and the step text. + """ + widget_ref = step.get("widget_ref") + step_text = step.get("text", "") + + if isinstance(widget_ref, (louie.saferef.BoundMethodWeakref, weakref.ReferenceType)): + target = widget_ref() + else: + target = widget_ref + + if target is None: + return None, step_text + + if callable(target) and not isinstance(target, (QWidget, QAction)): + result = target() + if isinstance(result, tuple): + target, alt_text = result + if alt_text: + step_text = alt_text + else: + target = result + + return target, step_text + + def _get_highlight_rect( + self, main_window: QWidget, target: QWidget | QAction, step_title: str + ) -> QRect | None: + """ + Get the QRect in main_window coordinates to highlight for the given target. + + Args: + main_window(QWidget): The main window containing the target. + target(QWidget | QAction): The target widget or action to highlight. + step_title(str): The title of the current step (for logging purposes). + + Returns: + QRect | None: The rectangle to highlight, or None if not found/visible. + """ + if isinstance(target, QAction): + rect = self._action_highlight_rect(target) + if rect is None: + self._advance_past_invalid_step( + step_title, + reason=f"Could not find visible widget or menu for QAction {target.text()!r}.", + ) + return None + return rect + + if isinstance(target, QWidget): + if self._visible_check: + if not target.isVisible(): + self._advance_past_invalid_step( + step_title, reason=f"Widget {target!r} is not visible." + ) + return None + rect = target.rect() + top_left = target.mapTo(main_window, rect.topLeft()) + return QRect(top_left, rect.size()) + + self._advance_past_invalid_step( + step_title, reason=f"Unsupported step target type: {type(target)}" + ) + return None + + def _advance_past_invalid_step(self, step_title: str, *, reason: str): + """ + Skip the current step (or stop the tour) when the target cannot be visualised. + """ + logger.warning("%s Skipping step %r.", reason, step_title) + if self._current_index < len(self._tour_steps) - 1: + self._current_index += 1 + self._show_current_step() + else: + self.stop_tour() + def get_registered_widgets(self) -> Dict[str, TourStep]: """Get all registered widgets.""" return self._registered_widgets.copy() @@ -484,6 +594,10 @@ def clear_registrations(self): self._tour_steps.clear() logger.info("Cleared all registrations") + def set_visibility_enforcement(self, enabled: bool): + """Enable or disable visibility checks when highlighting widgets.""" + self._visible_check = enabled + def eventFilter(self, obj, event): """Handle window resize/move events to update step positioning.""" if event.type() in (QEvent.Type.Move, QEvent.Type.Resize): @@ -500,52 +614,115 @@ def eventFilter(self, obj, event): class MainWindow(QMainWindow): # pragma: no cover def __init__(self): super().__init__() - self.setWindowTitle("Guided Help Demo") + self.setWindowTitle("Guided Tour Demo") central = QWidget() layout = QVBoxLayout(central) + layout.setSpacing(12) - # Create demo widgets - self.btn1 = QPushButton("Button 1") - self.btn2 = QPushButton("Button 2") + layout.addWidget(QLabel("Welcome to the guided tour demo with toolbar support.")) + self.btn1 = QPushButton("Primary Button") + self.btn2 = QPushButton("Secondary Button") + self.status_label = QLabel("Use the controls below or the toolbar to interact.") self.start_tour_btn = QPushButton("Start Guided Tour") - layout.addWidget(QLabel("Welcome to the Guided Help Demo!")) layout.addWidget(self.btn1) layout.addWidget(self.btn2) - layout.addWidget(self.start_tour_btn) + layout.addWidget(self.status_label) layout.addStretch() + layout.addWidget(self.start_tour_btn) self.setCentralWidget(central) - # Create guided help system + # Guided tour system self.guided_help = GuidedTour(self) - # Register widgets with help text - self.guided_help.register_widget( - widget=self.btn1, - text="This is the first button. It demonstrates how to highlight a widget and show help text next to it.", - title="First Button", - ) - - def widget_with_alt_text(): - import numpy as np + # Menus for demonstrating QAction support in menu bars + self._init_menu_bar() - if np.random.randint(0, 10) % 2 == 0: - return (self.btn2, None) - return (self.btn2, "This is an alternative help text for Button 2.") + # Modular toolbar showcasing QAction targets + self._init_toolbar() - self.guided_help.register_widget( - widget=widget_with_alt_text, - text="This is the second button. Notice how the help tooltip is positioned smartly to stay visible.", - title="Second Button", + # Register widgets and actions with help text + primary_step = self.guided_help.register_widget( + widget=self.btn1, + text="The primary button updates the status text when clicked.", + title="Primary Button", + ) + secondary_step = self.guided_help.register_widget( + widget=self.btn2, + text="The secondary button complements the demo layout.", + title="Secondary Button", + ) + toolbar_action_step = self.guided_help.register_widget( + widget=self.toolbar_tour_action.action, + text="Toolbar actions are supported in the guided tour. This one also starts the tour.", + title="Toolbar Tour Action", + ) + tools_menu_step = self.guided_help.register_widget( + widget=self.toolbar.components.get_action("menu_tools"), + text="Expandable toolbar menus group related actions. This button opens the tools menu.", + title="Tools Menu", ) # Create tour from registered widgets - widget_ids = list(self.guided_help.get_registered_widgets().keys()) + self.tour_step_ids = [primary_step, secondary_step, toolbar_action_step, tools_menu_step] + widget_ids = self.tour_step_ids self.guided_help.create_tour(widget_ids) # Connect start button self.start_tour_btn.clicked.connect(self.guided_help.start_tour) + def _init_menu_bar(self): + menu_bar = self.menuBar() + info_menu = menu_bar.addMenu("Info") + info_menu.setObjectName("info-menu") + self.info_menu = info_menu + self.info_menu_action = info_menu.menuAction() + self.about_action = info_menu.addAction("About This Demo") + + def _init_toolbar(self): + self.toolbar = ModularToolBar(parent=self) + self.addToolBar(self.toolbar) + + self.toolbar_tour_action = MaterialIconAction( + "play_circle", tooltip="Start the guided tour", parent=self + ) + self.toolbar.components.add_safe("tour-start", self.toolbar_tour_action) + + self.toolbar_highlight_action = MaterialIconAction( + "visibility", tooltip="Highlight the primary button", parent=self + ) + self.toolbar.components.add_safe("inspect-primary", self.toolbar_highlight_action) + + demo_bundle = self.toolbar.new_bundle("demo") + demo_bundle.add_action("tour-start") + demo_bundle.add_action("inspect-primary") + + self._setup_tools_menu() + self.toolbar.show_bundles(["demo", "menu_tools"]) + self.toolbar.refresh() + + self.toolbar_tour_action.action.triggered.connect(self.guided_help.start_tour) + + def _setup_tools_menu(self): + self.tools_menu_actions: dict[str, MaterialIconAction] = { + "notes": MaterialIconAction( + icon_name="note_add", tooltip="Add a note", filled=True, parent=self + ), + "bookmark": MaterialIconAction( + icon_name="bookmark_add", tooltip="Bookmark current view", filled=True, parent=self + ), + "settings": MaterialIconAction( + icon_name="tune", tooltip="Adjust settings", filled=True, parent=self + ), + } + self.tools_menu_action = ExpandableMenuAction( + label="Tools ", actions=self.tools_menu_actions + ) + self.toolbar.components.add_safe("menu_tools", self.tools_menu_action) + bundle = ToolbarBundle("menu_tools", self.toolbar.components) + bundle.add_action("menu_tools") + self.toolbar.add_bundle(bundle) + if __name__ == "__main__": # pragma: no cover app = QApplication(sys.argv) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 3ac67fc09..9589bd419 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import weakref from abc import ABC, abstractmethod from contextlib import contextmanager from typing import Dict, Literal @@ -25,7 +26,6 @@ ) import bec_widgets -from bec_widgets.utils.guided_tour import GuidedTour from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox @@ -507,6 +507,8 @@ class ExpandableMenuAction(ToolBarAction): def __init__(self, label: str, actions: dict, icon_path: str = None): super().__init__(icon_path, label) self.actions = actions + self._button_ref: weakref.ReferenceType[QToolButton] | None = None + self._menu_ref: weakref.ReferenceType[QMenu] | None = None def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): button = QToolButton(toolbar) @@ -542,6 +544,14 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): menu.addAction(action) button.setMenu(menu) toolbar.addWidget(button) + self._button_ref = weakref.ref(button) + self._menu_ref = weakref.ref(menu) + + def get_toolbar_button(self) -> QToolButton | None: + return self._button_ref() if self._button_ref else None + + def get_menu(self) -> QMenu | None: + return self._menu_ref() if self._menu_ref else None class DeviceComboBoxAction(WidgetAction): @@ -613,6 +623,8 @@ def __init__(self, main_window: QWidget, tooltip: str = "Start Guided Tutorial", parent=parent, ) + from bec_widgets.utils.guided_tour import GuidedTour + self.guided_help = GuidedTour(main_window) self.main_window = main_window diff --git a/tests/unit_tests/test_guided_tour.py b/tests/unit_tests/test_guided_tour.py index bb3f7a79d..41d3320ad 100644 --- a/tests/unit_tests/test_guided_tour.py +++ b/tests/unit_tests/test_guided_tour.py @@ -1,9 +1,11 @@ from unittest import mock import pytest -from qtpy.QtWidgets import QWidget +from qtpy.QtWidgets import QVBoxLayout, QWidget from bec_widgets.utils.guided_tour import GuidedTour +from bec_widgets.utils.toolbars.actions import ExpandableMenuAction, MaterialIconAction +from bec_widgets.utils.toolbars.toolbar import ModularToolBar @pytest.fixture @@ -18,7 +20,7 @@ def main_window(qtbot): @pytest.fixture def guided_help(main_window): """Create a GuidedTour instance for testing.""" - return GuidedTour(main_window) + return GuidedTour(main_window, enforce_visibility=False) @pytest.fixture @@ -244,6 +246,33 @@ def test_step_counter_display(self, guided_help: GuidedTour, test_widget: QWidge assert overlay is not None assert overlay.step_label.text() == "Step 1 of 2" + def test_register_expandable_menu_action(self, qtbot): + """Ensure toolbar menu actions can be registered directly.""" + window = QWidget() + layout = QVBoxLayout(window) + toolbar = ModularToolBar(parent=window) + layout.addWidget(toolbar) + qtbot.addWidget(window) + + tools_action = ExpandableMenuAction( + label="Tools ", + actions={ + "notes": MaterialIconAction( + icon_name="note_add", tooltip="Add note", filled=True, parent=window + ) + }, + ) + toolbar.components.add_safe("menu_tools", tools_action) + bundle = toolbar.new_bundle("menu_tools") + bundle.add_action("menu_tools") + toolbar.show_bundles(["menu_tools"]) + + guided = GuidedTour(window, enforce_visibility=False) + guided.register_widget(widget=tools_action, text="Toolbar tools menu") + guided.start_tour() + + assert guided._active is True + @mock.patch("bec_widgets.utils.guided_tour.logger") def test_error_handling(self, mock_logger, guided_help): """Test error handling and logging.""" From 79d64c43d5aa571f1a82d62ec2b09d3ef7c43984 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 4 Nov 2025 15:27:34 +0100 Subject: [PATCH 115/161] refactor(developer_view): developer view moved from examples to view module in applications --- bec_widgets/applications/main_app.py | 2 +- .../{examples => applications/views}/developer_view/__init__.py | 0 .../views}/developer_view/developer_view.py | 2 +- .../views}/developer_view/developer_widget.py | 0 tests/unit_tests/test_developer_view.py | 2 +- 5 files changed, 3 insertions(+), 3 deletions(-) rename bec_widgets/{examples => applications/views}/developer_view/__init__.py (100%) rename bec_widgets/{examples => applications/views}/developer_view/developer_view.py (95%) rename bec_widgets/{examples => applications/views}/developer_view/developer_widget.py (100%) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 511f82a26..64a6b6053 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -3,11 +3,11 @@ 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.developer_view.developer_view import DeveloperView from bec_widgets.applications.views.device_manager_view.device_manager_widget import ( DeviceManagerWidget, ) from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup -from bec_widgets.examples.developer_view.developer_view import DeveloperView 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 diff --git a/bec_widgets/examples/developer_view/__init__.py b/bec_widgets/applications/views/developer_view/__init__.py similarity index 100% rename from bec_widgets/examples/developer_view/__init__.py rename to bec_widgets/applications/views/developer_view/__init__.py diff --git a/bec_widgets/examples/developer_view/developer_view.py b/bec_widgets/applications/views/developer_view/developer_view.py similarity index 95% rename from bec_widgets/examples/developer_view/developer_view.py rename to bec_widgets/applications/views/developer_view/developer_view.py index a6040e3c9..3b28a3920 100644 --- a/bec_widgets/examples/developer_view/developer_view.py +++ b/bec_widgets/applications/views/developer_view/developer_view.py @@ -1,7 +1,7 @@ from qtpy.QtWidgets import QWidget +from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget from bec_widgets.applications.views.view import ViewBase -from bec_widgets.examples.developer_view.developer_widget import DeveloperWidget class DeveloperView(ViewBase): diff --git a/bec_widgets/examples/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py similarity index 100% rename from bec_widgets/examples/developer_view/developer_widget.py rename to bec_widgets/applications/views/developer_view/developer_widget.py diff --git a/tests/unit_tests/test_developer_view.py b/tests/unit_tests/test_developer_view.py index 6a4d5d9af..56971d3b7 100644 --- a/tests/unit_tests/test_developer_view.py +++ b/tests/unit_tests/test_developer_view.py @@ -17,7 +17,7 @@ import pytest from qtpy.QtWidgets import QDialog -from bec_widgets.examples.developer_view.developer_widget import DeveloperWidget +from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer From 8776784507bedd1280beaeb601e8bbc1dfa6497d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 4 Nov 2025 15:52:03 +0100 Subject: [PATCH 116/161] refactor(collapsible_tree_section): change add pushbuttons to qtoolbuttons --- .../containers/explorer/collapsible_tree_section.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index 95975b031..5eec83d75 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -3,7 +3,7 @@ from bec_qthemes import material_icon from qtpy.QtCore import QMimeData, Qt, Signal from qtpy.QtGui import QDrag -from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget +from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.error_popups import SafeProperty @@ -66,13 +66,16 @@ def __init__( header_layout.addWidget(self.header_button) header_layout.addStretch() - self.header_add_button = QPushButton() + # Add button in header (icon-only) + self.header_add_button = QToolButton() self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - self.header_add_button.setFixedSize(20, 20) + self.header_add_button.setFixedSize(28, 28) self.header_add_button.setToolTip("Add item") self.header_add_button.setVisible(show_add_button) + self.header_add_button.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.header_add_button.setAutoRaise(True) - self.header_add_button.setIcon(material_icon("add", size=(20, 20))) + self.header_add_button.setIcon(material_icon("add", size=(28, 28))) header_layout.addWidget(self.header_add_button) self.main_layout.addLayout(header_layout) From 7f488c09ef9799311b27558d20d7fa37203f65ee Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 28 Oct 2025 20:39:19 +0100 Subject: [PATCH 117/161] fix(becconnector): ophyd thread killer on exit + in conftest --- bec_widgets/utils/bec_connector.py | 11 +++++++++++ tests/unit_tests/conftest.py | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index b670d03a7..9c820c7a0 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -129,6 +129,17 @@ def __init__( def terminate(client=self.client, dispatcher=self.bec_dispatcher): logger.info("Disconnecting", repr(dispatcher)) dispatcher.disconnect_all() + + try: # shutdown ophyd threads if any + from ophyd._pyepics_shim import _dispatcher + + _dispatcher.stop() + logger.info("Ophyd dispatcher shut down successfully.") + except Exception as e: + logger.warning( + f"Error shutting down ophyd dispatcher: {e}\n{traceback.format_exc()}" + ) + logger.info("Shutting down BEC Client", repr(client)) client.shutdown() diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index bfd7ddb64..47a7a1c76 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -41,6 +41,10 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus # if the test failed, we don't want to check for open widgets as # it simply pollutes the output + # stop pyepics dispatcher for leaking tests + from ophyd._pyepics_shim import _dispatcher + + _dispatcher.stop() if request.node.stash._storage.get("failed"): print("Test failed, skipping cleanup checks") return From 0ba14b2dbb30e13fb290c60540dfe3a7e894e3e5 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 17 Nov 2025 13:17:54 +0100 Subject: [PATCH 118/161] WILL BE REMOVED AFTER REBASE build: pyqtgraph pin to 0.13.7 (cherry picked from commit a4c465dcaf8cb03962dec1e360b7b832a9a5c780) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ec81c2528..ea85f719a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "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", + "pyqtgraph==0.13.7", "PySide6==6.9.0", "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", From 937bea5103a615e5b3607213f2de1071ffbda058 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 17 Nov 2025 13:18:20 +0100 Subject: [PATCH 119/161] WILL BE REMOVED AFTER REBASE fix(test): removed duplicate test in crosshair (cherry picked from commit d00d786399bca516b8030b9de881b674140bf439) --- tests/unit_tests/test_crosshair.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/unit_tests/test_crosshair.py b/tests/unit_tests/test_crosshair.py index e1461e70f..28662a270 100644 --- a/tests/unit_tests/test_crosshair.py +++ b/tests/unit_tests/test_crosshair.py @@ -193,21 +193,6 @@ def slot(position): assert np.isclose(y, 5) -def test_marker_positions_after_mouse_move(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair - - pos_in_view = QPointF(2, 5) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - crosshair.mouse_moved(event_mock) - - marker = crosshair.marker_moved_1d["Curve 1"] - marker_x, marker_y = marker.getData() - assert marker_x == [2] - assert marker_y == [5] - - def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair): crosshair, plot_item = plot_widget_with_crosshair From 38134ec84917087afc22e04dc9e4837ba418a073 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Mon, 17 Nov 2025 20:11:58 +0100 Subject: [PATCH 120/161] WILL BE REMOVED AFTER REBASE fix(fakeredis): add support for additional args (cherry picked from commit c9455672b58b9df101ccd0d80a169bdf6c707f34) --- tests/unit_tests/client_mocks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/client_mocks.py b/tests/unit_tests/client_mocks.py index b64a65ee3..613119e02 100644 --- a/tests/unit_tests/client_mocks.py +++ b/tests/unit_tests/client_mocks.py @@ -12,7 +12,7 @@ from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner -def fake_redis_server(host, port): +def fake_redis_server(host, port, **kwargs): redis = fakeredis.FakeRedis() return redis From 5b364c5aa53083706d7a817b0fc11cfaf7f23d8a Mon Sep 17 00:00:00 2001 From: David Perl Date: Tue, 18 Nov 2025 11:44:41 +0100 Subject: [PATCH 121/161] refactor: improve toolbar actions typing --- bec_widgets/utils/toolbars/actions.py | 80 ++++++++++++++------------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 9589bd419..5c0b0955e 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -11,7 +11,7 @@ from bec_lib.logger import bec_logger from bec_qthemes._icon.material_icons import material_icon from qtpy.QtCore import QSize, Qt, QTimer -from qtpy.QtGui import QAction, QColor, QIcon +from qtpy.QtGui import QAction, QColor, QIcon # type: ignore from qtpy.QtWidgets import ( QApplication, QComboBox, @@ -53,9 +53,9 @@ def create_action_with_text(toolbar_action, toolbar: QToolBar): btn.setDefaultAction(toolbar_action.action) btn.setAutoRaise(True) if toolbar_action.text_position == "beside": - btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) else: - btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) btn.setText(toolbar_action.label_text) toolbar.addWidget(btn) @@ -66,7 +66,7 @@ class NoCheckDelegate(QStyledItemDelegate): def initStyleOption(self, option, index): super().initStyleOption(option, index) # Remove any check indicator - option.checkState = Qt.Unchecked + option.checkState = Qt.CheckState.Unchecked class LongPressToolButton(QToolButton): @@ -111,13 +111,15 @@ class ToolBarAction(ABC): checkable (bool, optional): Whether the action is checkable. Defaults to False. """ - def __init__(self, icon_path: str = None, tooltip: str = None, checkable: bool = False): + def __init__( + self, icon_path: str | None = None, tooltip: str | None = None, checkable: bool = False + ): self.icon_path = ( os.path.join(MODULE_PATH, "assets", "toolbar_icons", icon_path) if icon_path else None ) - self.tooltip = tooltip - self.checkable = checkable - self.action = None + self.tooltip: str = tooltip or "" + self.checkable: bool = checkable + self.action: QAction @abstractmethod def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): @@ -133,6 +135,11 @@ def cleanup(self): pass +class IconAction(ToolBarAction): + @abstractmethod + def get_icon(self) -> QIcon: ... + + class SeparatorAction(ToolBarAction): """Separator action for the toolbar.""" @@ -140,7 +147,7 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar.addSeparator() -class QtIconAction(ToolBarAction): +class QtIconAction(IconAction): def __init__( self, standard_icon, @@ -179,13 +186,13 @@ def get_icon(self): return self.icon -class MaterialIconAction(ToolBarAction): +class MaterialIconAction(IconAction): """ Action with a Material icon for the toolbar. Args: - icon_name (str, optional): The name of the Material icon. Defaults to None. - tooltip (str, optional): The tooltip for the action. Defaults to None. + icon_name (str): The name of the Material icon. + tooltip (str): The tooltip for the action. checkable (bool, optional): Whether the action is checkable. Defaults to False. filled (bool, optional): Whether the icon is filled. Defaults to False. color (str | tuple | QColor | dict[Literal["dark", "light"], str] | None, optional): The color of the icon. @@ -197,8 +204,9 @@ class MaterialIconAction(ToolBarAction): def __init__( self, - icon_name: str = None, - tooltip: str = None, + icon_name: str, + tooltip: str, + *, checkable: bool = False, filled: bool = False, color: str | tuple | QColor | dict[Literal["dark", "light"], str] | None = None, @@ -217,13 +225,13 @@ def __init__( self.label_text = label_text self.text_position = text_position # Generate the icon using the material_icon helper - self.icon = material_icon( + self.icon: QIcon = material_icon( self.icon_name, size=(20, 20), convert_to_pixmap=False, filled=self.filled, color=self.color, - ) + ) # type: ignore if parent is None: logger.warning( "MaterialIconAction was created without a parent. Please consider adding one. Using None as parent may cause issues." @@ -259,11 +267,11 @@ class DeviceSelectionAction(ToolBarAction): Action for selecting a device in a combobox. Args: - label (str): The label for the combobox. device_combobox (DeviceComboBox): The combobox for selecting the device. + label (str): The label for the combobox. """ - def __init__(self, label: str | None = None, device_combobox=None): + def __init__(self, /, device_combobox: DeviceComboBox, label: str | None = None): super().__init__() self.label = label self.device_combobox = device_combobox @@ -285,7 +293,7 @@ def set_combobox_style(self, color: str): self.device_combobox.setStyleSheet(f"QComboBox {{ background-color: {color}; }}") -class SwitchableToolBarAction(ToolBarAction): +class SwitchableToolBarAction(IconAction): """ A split toolbar action that combines a main action and a drop-down menu for additional actions. @@ -305,9 +313,9 @@ class SwitchableToolBarAction(ToolBarAction): def __init__( self, - actions: Dict[str, ToolBarAction], - initial_action: str = None, - tooltip: str = None, + actions: Dict[str, IconAction], + initial_action: str | None = None, + tooltip: str | None = None, checkable: bool = True, default_state_checked: bool = False, parent=None, @@ -330,11 +338,11 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): target (QWidget): The target widget for the action. """ self.main_button = LongPressToolButton(toolbar) - self.main_button.setPopupMode(QToolButton.MenuButtonPopup) + self.main_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) self.main_button.setCheckable(self.checkable) default_action = self.actions[self.current_key] self.main_button.setIcon(default_action.get_icon()) - self.main_button.setToolTip(default_action.tooltip) + self.main_button.setToolTip(default_action.tooltip or "") self.main_button.clicked.connect(self._trigger_current_action) menu = QMenu(self.main_button) for key, action_obj in self.actions.items(): @@ -432,11 +440,7 @@ class WidgetAction(ToolBarAction): """ def __init__( - self, - label: str | None = None, - widget: QWidget = None, - adjust_size: bool = True, - parent=None, + self, *, widget: QWidget, label: str | None = None, adjust_size: bool = True, parent=None ): super().__init__(icon_path=None, tooltip=label, checkable=False) self.label = label @@ -459,14 +463,14 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): if self.label is not None: label_widget = QLabel(text=f"{self.label}", parent=target) - label_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) - label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight) + label_widget.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + label_widget.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) layout.addWidget(label_widget) if isinstance(self.widget, QComboBox) and self.adjust_size: - self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents) + self.widget.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents) - size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + size_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.widget.setSizePolicy(size_policy) self.widget.setMinimumWidth(self.calculate_minimum_width(self.widget)) @@ -475,7 +479,7 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar.addWidget(self.container) # Store the container as the action to allow toggling visibility. - self.action = self.container + self.action = self.container # type: ignore def cleanup(self): """ @@ -490,7 +494,7 @@ def cleanup(self): @staticmethod def calculate_minimum_width(combo_box: QComboBox) -> int: font_metrics = combo_box.fontMetrics() - max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) + max_width = max(font_metrics.width(combo_box.itemText(i)) for i in range(combo_box.count())) # type: ignore return max_width + 60 @@ -504,7 +508,7 @@ class ExpandableMenuAction(ToolBarAction): icon_path (str, optional): The path to the icon file. Defaults to None. """ - def __init__(self, label: str, actions: dict, icon_path: str = None): + def __init__(self, label: str, actions: dict[str, IconAction], icon_path: str | None = None): super().__init__(icon_path, label) self.actions = actions self._button_ref: weakref.ReferenceType[QToolButton] | None = None @@ -517,7 +521,7 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): if self.icon_path: button.setIcon(QIcon(self.icon_path)) button.setText(self.tooltip) - button.setPopupMode(QToolButton.InstantPopup) + button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) button.setStyleSheet( """ QToolButton { @@ -643,7 +647,7 @@ def register_widget(self, widget: QWidget, text: str, widget_name: str = "") -> Returns: str: Unique ID for the registered widget. """ - return self.guided_help.register_widget(widget, text, widget_name) + return self.guided_help.register_widget(widget=widget, text=text, title=widget_name) def start_tour(self): """Start the guided tour with all registered widgets.""" From 578be47880a9fc67141057264cc27e44e703c24d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 20 Oct 2025 13:42:31 +0200 Subject: [PATCH 122/161] feat(bec_widget): save screenshot to bytes --- bec_widgets/utils/bec_widget.py | 42 +++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index ef397d03c..1660f58c0 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -6,7 +6,8 @@ import PySide6QtAds as QtAds import shiboken6 from bec_lib.logger import bec_logger -from qtpy.QtCore import QObject +from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt +from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QApplication, QFileDialog, QWidget from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -57,7 +58,6 @@ def __init__( theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the widget's apply_theme method will be called when the theme changes. """ - super().__init__( client=client, config=config, gui_id=gui_id, parent_dock=parent_dock, **kwargs ) @@ -218,6 +218,44 @@ def screenshot(self, file_name: str | None = None): screenshot.save(file_name) logger.info(f"Screenshot saved to {file_name}") + def screenshot_bytes( + self, + *, + max_width: int | None = None, + max_height: int | None = None, + fmt: str = "PNG", + quality: int = -1, + ) -> QByteArray: + """ + Grab this widget, optionally scale to a max size, and return encoded image bytes. + + If max_width/max_height are omitted (the default), capture at full resolution. + + Args: + max_width(int, optional): Maximum width of the screenshot. + max_height(int, optional): Maximum height of the screenshot. + fmt(str, optional): Image format (e.g., "PNG", "JPEG"). + quality(int, optional): Image quality (0-100), -1 for default. + + Returns: + QByteArray: The screenshot image bytes. + """ + if not isinstance(self, QWidget): + return QByteArray() + pixmap: QPixmap = self.grab() + if pixmap.isNull(): + return QByteArray() + if max_width is not None or max_height is not None: + w = max_width if max_width is not None else pixmap.width() + h = max_height if max_height is not None else pixmap.height() + pixmap = pixmap.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation) + ba = QByteArray() + buf = QBuffer(ba) + buf.open(QIODevice.WriteOnly) + pixmap.save(buf, fmt, quality) + buf.close() + return ba + def attach(self): dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget) if dock is None: From 1ab915468b0dfce5652400c3542141b0d2dc9a9e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 9 Nov 2025 20:32:39 +0100 Subject: [PATCH 123/161] fix(widget_state_manager): added shiboken check --- bec_widgets/utils/widget_state_manager.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 2efe56e78..e5f94bdc5 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shiboken6 from bec_lib import bec_logger from qtpy.QtCore import QSettings from qtpy.QtWidgets import ( @@ -84,6 +85,9 @@ def _save_widget_state_qsettings( settings(QSettings): The QSettings object to save the state to. recursive(bool): Whether to recursively save the state of child widgets. """ + if widget is None or not shiboken6.isValid(widget): + return + if widget.property("skip_settings") is True: return @@ -115,11 +119,14 @@ def _save_widget_state_qsettings( ) # to avoid duplicates for child in all_children: if ( - child.objectName() + child + and shiboken6.isValid(child) + and child.objectName() and child.property("skip_settings") is not True and not isinstance(child, QLabel) ): self._save_widget_state_qsettings(child, settings, False) + logger.info(f"Saved state for widget '{widget_name}'") def _load_widget_state_qsettings( self, widget: QWidget, settings: QSettings, recursive: bool = True @@ -132,6 +139,9 @@ def _load_widget_state_qsettings( settings(QSettings): The QSettings object to load the state from. recursive(bool): Whether to recursively load the state of child widgets. """ + if widget is None or not shiboken6.isValid(widget): + return + if widget.property("skip_settings") is True: return @@ -156,7 +166,9 @@ def _load_widget_state_qsettings( ) # to avoid duplicates for child in all_children: if ( - child.objectName() + child + and shiboken6.isValid(child) + and child.objectName() and child.property("skip_settings") is not True and not isinstance(child, QLabel) ): From a20d8b269d252cf2dea547e59f84788048eee577 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 9 Nov 2025 20:32:52 +0100 Subject: [PATCH 124/161] fix(dark_mode_button): skip settings added --- .../widgets/utility/visual/dark_mode_button/dark_mode_button.py | 1 + 1 file changed, 1 insertion(+) 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 6fdb1f15a..840e56ad4 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 @@ -23,6 +23,7 @@ def __init__( **kwargs, ) -> None: super().__init__(parent=parent, client=client, gui_id=gui_id, theme_update=True, **kwargs) + self.setProperty("skip_settings", True) self._dark_mode_enabled = False self.layout = QHBoxLayout(self) From 652bf2d00b0cb4122ecd18dbaf05ac5aaa2ce63e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 26 Oct 2025 20:00:00 +0100 Subject: [PATCH 125/161] fix(main_window): cleanup adjusted with shiboken6 --- bec_widgets/widgets/containers/main_window/main_window.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 78719e08f..16f8dfe37 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -2,6 +2,7 @@ import os +import shiboken6 from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer from qtpy.QtGui import QAction, QActionGroup, QIcon @@ -468,9 +469,9 @@ def cleanup(self): for child in children: ancestor = WidgetHierarchy._get_becwidget_ancestor(child) if ancestor is self: - child.cleanup() - child.close() - child.deleteLater() + if shiboken6.isValid(child): + child.close() + child.deleteLater() # Timer cleanup if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive(): From b9643691b9d7296112016ea43d9e08f5ed00846c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 6 Oct 2025 13:37:52 +0200 Subject: [PATCH 126/161] feat(advanced_dock_area): UI/UX for profile management improved, saving directories logic adjusted --- .../advanced_dock_area/advanced_dock_area.py | 410 +++++---- .../advanced_dock_area/profile_utils.py | 455 +++++++++- .../advanced_dock_area/settings/__init__.py | 0 .../advanced_dock_area/settings/dialogs.py | 325 +++++++ .../settings/workspace_manager.py | 404 +++++++++ .../advanced_dock_area/states/user/test.ini | 234 ----- .../toolbar_components/workspace_actions.py | 133 +-- tests/unit_tests/test_advanced_dock_area.py | 853 ++++++++++++++++-- 8 files changed, 2271 insertions(+), 543 deletions(-) create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py delete mode 100644 bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini 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 8bdee4a7e..e72224d14 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 @@ -4,18 +4,15 @@ from typing import Literal, cast import PySide6QtAds as QtAds +from bec_lib import bec_logger from PySide6QtAds import CDockManager, CDockWidget -from qtpy.QtCore import Signal +from qtpy.QtCore import QTimer, Signal +from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( QApplication, - QCheckBox, QDialog, - QHBoxLayout, QInputDialog, - QLabel, - QLineEdit, QMessageBox, - QPushButton, QSizePolicy, QVBoxLayout, QWidget, @@ -37,14 +34,30 @@ 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, + default_profile_path, + get_last_profile, + is_quick_select, + load_default_profile_screenshot, + load_user_profile_screenshot, + now_iso_utc, + open_default_settings, + open_user_settings, + profile_origin, + profile_origin_display, read_manifest, - set_profile_readonly, + restore_user_from_default, + set_last_profile, + set_quick_select, + user_profile_path, write_manifest, ) +from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import ( + RestoreProfileDialog, + SaveProfileDialog, +) +from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import ( + WorkSpaceManager, +) from bec_widgets.widgets.containers.advanced_dock_area.toolbar_components.workspace_actions import ( WorkspaceConnection, workspace_bundle, @@ -65,6 +78,8 @@ from bec_widgets.widgets.utility.logpanel import LogPanel from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton +logger = bec_logger.logger + class DockSettingsDialog(QDialog): @@ -79,62 +94,6 @@ def __init__(self, parent: QWidget, target: QWidget): layout.addWidget(self.prop_editor) -class SaveProfileDialog(QDialog): - """Dialog for saving workspace profiles with read-only option.""" - - def __init__(self, parent: QWidget, current_name: str = ""): - super().__init__(parent) - self.setWindowTitle("Save Workspace Profile") - self.setModal(True) - self.resize(400, 150) - layout = QVBoxLayout(self) - - # Name input - name_row = QHBoxLayout() - name_row.addWidget(QLabel("Profile Name:")) - self.name_edit = QLineEdit(current_name) - self.name_edit.setPlaceholderText("Enter profile name...") - name_row.addWidget(self.name_edit) - layout.addLayout(name_row) - - # Read-only checkbox - self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)") - layout.addWidget(self.readonly_checkbox) - - # Info label - info_label = QLabel("Read-only profiles are protected from modification and deletion.") - info_label.setStyleSheet("color: gray; font-size: 10px;") - layout.addWidget(info_label) - - # Buttons - btn_row = QHBoxLayout() - btn_row.addStretch(1) - self.save_btn = QPushButton("Save") - self.save_btn.setDefault(True) - cancel_btn = QPushButton("Cancel") - self.save_btn.clicked.connect(self.accept) - cancel_btn.clicked.connect(self.reject) - btn_row.addWidget(self.save_btn) - btn_row.addWidget(cancel_btn) - layout.addLayout(btn_row) - - # Enable/disable save button based on name input - self.name_edit.textChanged.connect(self._update_save_button) - self._update_save_button() - - def _update_save_button(self): - """Enable save button only when name is not empty.""" - self.save_btn.setEnabled(bool(self.name_edit.text().strip())) - - def get_profile_name(self) -> str: - """Get the entered profile name.""" - return self.name_edit.text().strip() - - def is_readonly(self) -> bool: - """Check if the profile should be marked as read-only.""" - return self.readonly_checkbox.isChecked() - - class AdvancedDockArea(BECWidget, QWidget): RPC = True PLUGIN = False @@ -151,6 +110,7 @@ class AdvancedDockArea(BECWidget, QWidget): # Define a signal for mode changes mode_changed = Signal(str) + profile_changed = Signal(str) def __init__( self, @@ -190,12 +150,18 @@ def __init__( self._setup_toolbar() self._hook_toolbar() + # Popups + self.save_dialog = None + self.manage_dialog = None + # 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() + self._current_profile_name = None + self._pending_autosave_skip: tuple[str, str] | None = None # State manager self.state_manager = WidgetStateManager(self) @@ -211,6 +177,23 @@ def __init__( # Apply the requested mode after everything is set up self.mode = mode + QTimer.singleShot( + 0, self._fetch_initial_profile + ) # To allow full init before loading profile and prevent segfault on exit + + def _fetch_initial_profile(self): + # Restore last-used profile if available; otherwise fall back to combo selection + combo = self.toolbar.components.get_action("workspace_combo").widget + last = get_last_profile() + if last and ( + os.path.exists(user_profile_path(last)) or os.path.exists(default_profile_path(last)) + ): + init_profile = last + else: + init_profile = combo.currentText() + if init_profile: + self.load_profile(init_profile) + combo.setCurrentText(init_profile) def _make_dock( self, @@ -494,11 +477,6 @@ 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) @@ -689,63 +667,64 @@ def lock_workspace(self, value: bool): 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) + def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: + settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry()) + settings.setValue(SETTINGS_KEYS["state"], b"") + settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState()) + self.dock_manager.addPerspective(self.windowTitle()) + self.dock_manager.savePerspectives(settings) + self.state_manager.save_state(settings=settings) + write_manifest(settings, self.dock_list()) + if save_preview: + ba = self.screenshot_bytes() + if ba and len(ba) > 0: + settings.setValue(SETTINGS_KEYS["screenshot"], ba) + settings.setValue(SETTINGS_KEYS["screenshot_at"], now_iso_utc()) + + logger.info(f"Workspace snapshot written to settings: {settings.fileName()}") + @SafeSlot(str) def save_profile(self, name: str | None = None): """ Save the current workspace profile. + On first save of a given name: + - writes a default copy to states/default/.ini with tag=default and created_at + - writes a user copy to states/user/.ini with tag=user and created_at + On subsequent saves of user-owned profiles: + - updates both the default and user copies so restore uses the latest snapshot. + Read-only bundled profiles cannot be overwritten. + Args: - name (str | None): The name of the profile. If None, a dialog will prompt for a name. + name (str | None): The name of the profile to save. If None, prompts the user. """ - 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 + def _profile_exists(profile_name: str) -> bool: + return profile_origin(profile_name) != "unknown" + + initial_name = name or "" + quickselect_default = is_quick_select(name) if name else False + + current_profile = getattr(self, "_current_profile_name", "") or "" + dialog = SaveProfileDialog( + self, + current_name=initial_name, + current_profile_name=current_profile, + name_exists=_profile_exists, + profile_origin=profile_origin, + origin_label=profile_origin_display, + quick_select_checked=quickselect_default, + ) + if dialog.exec() != QDialog.Accepted: + return + + name = dialog.get_profile_name() + quickselect = dialog.is_quick_select() + origin_before_save = profile_origin(name) + overwrite_default = dialog.overwrite_existing and origin_before_save == "settings" # Display saving placeholder workspace_combo = self.toolbar.components.get_action("workspace_combo").widget workspace_combo.blockSignals(True) @@ -753,42 +732,75 @@ def save_profile(self, name: str | None = None): workspace_combo.setCurrentIndex(0) workspace_combo.blockSignals(False) - # Save the profile - settings = open_settings(name) - settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry()) - settings.setValue( - SETTINGS_KEYS["state"], b"" - ) # No QMainWindow state; placeholder for backward compat - settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState()) - self.dock_manager.addPerspective(name) - self.dock_manager.savePerspectives(settings) - self.state_manager.save_state(settings=settings) - write_manifest(settings, self.dock_list()) - - # Set read-only status if specified - if readonly: - set_profile_readonly(name, readonly) + # Create or update default copy controlled by overwrite flag + should_write_default = overwrite_default or not os.path.exists(default_profile_path(name)) + if should_write_default: + ds = open_default_settings(name) + self._write_snapshot_to_settings(ds) + if not ds.value(SETTINGS_KEYS["created_at"], ""): + ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + # Ensure new profiles are not quick-select by default + if not ds.value(SETTINGS_KEYS["is_quick_select"], None): + ds.setValue(SETTINGS_KEYS["is_quick_select"], False) + + # Always (over)write the user copy + us = open_user_settings(name) + self._write_snapshot_to_settings(us) + if not us.value(SETTINGS_KEYS["created_at"], ""): + us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + # Ensure new profiles are not quick-select by default (only if missing) + if not us.value(SETTINGS_KEYS["is_quick_select"], None): + us.setValue(SETTINGS_KEYS["is_quick_select"], False) + + # set quick select + if quickselect: + set_quick_select(name, quickselect) - settings.sync() self._refresh_workspace_list() + if current_profile and current_profile != name and not dialog.overwrite_existing: + self._pending_autosave_skip = (current_profile, name) + else: + self._pending_autosave_skip = None workspace_combo.setCurrentText(name) + self._current_profile_name = name + self.profile_changed.emit(name) + set_last_profile(name) + combo = self.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles(active_profile=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. + Before switching, persist the current profile to the user copy. + Prefer loading the user copy; fall back to the default copy. """ - # FIXME this has to be tweaked - if not name: + if not name: # Gui fallback if the name is not provided 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) + prev_name = getattr(self, "_current_profile_name", None) + skip_pair = getattr(self, "_pending_autosave_skip", None) + if prev_name and prev_name != name: + if skip_pair and skip_pair == (prev_name, name): + self._pending_autosave_skip = None + else: + us_prev = open_user_settings(prev_name) + self._write_snapshot_to_settings(us_prev, save_preview=False) + + # Choose source settings: user first, else default + if os.path.exists(user_profile_path(name)): + settings = open_user_settings(name) + elif os.path.exists(default_profile_path(name)): + settings = open_default_settings(name) + else: + QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.") + return + + # Rebuild widgets and restore states for item in read_manifest(settings): obj_name = item["object_name"] widget_class = item["widget_class"] @@ -806,8 +818,6 @@ def load_profile(self, name: str | None = None): geom = settings.value(SETTINGS_KEYS["geom"]) if geom: self.restoreGeometry(geom) - # No window state for QWidget-based host; keep for backwards compat read - # window_state = settings.value(SETTINGS_KEYS["state"]) # ignored dock_state = settings.value(SETTINGS_KEYS["ads_state"]) if dock_state: self.dock_manager.restoreState(dock_state) @@ -815,6 +825,42 @@ def load_profile(self, name: str | None = None): self.state_manager.load_state(settings=settings) self._set_editable(self._editable) + self._current_profile_name = name + self.profile_changed.emit(name) + set_last_profile(name) + combo = self.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles(active_profile=name) + + @SafeSlot() + @SafeSlot(str) + def restore_user_profile_from_default(self, name: str | None = None): + """ + Overwrite the user copy of *name* with the default baseline. + If *name* is None, target the currently active profile. + + Args: + name (str | None): The name of the profile to restore. If None, uses the current profile. + """ + target = name or getattr(self, "_current_profile_name", None) + if not target: + return + + current_pixmap = None + if self.isVisible(): + current_pixmap = QPixmap() + ba = bytes(self.screenshot_bytes()) + current_pixmap.loadFromData(ba) + if current_pixmap is None or current_pixmap.isNull(): + current_pixmap = load_user_profile_screenshot(target) + default_pixmap = load_default_profile_screenshot(target) + + if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap): + return + + restore_user_from_default(target) + self.delete_all() + self.load_profile(target) + @SafeSlot() def delete_profile(self): """ @@ -825,17 +871,6 @@ def delete_profile(self): 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, @@ -848,7 +883,7 @@ def delete_profile(self): if reply != QMessageBox.Yes: return - file_path = profile_path(name) + file_path = user_profile_path(name) try: os.remove(file_path) except FileNotFoundError: @@ -860,15 +895,69 @@ 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 + active_profile = getattr(self, "_current_profile_name", None) if hasattr(combo, "refresh_profiles"): - combo.refresh_profiles() + combo.refresh_profiles(active_profile) else: # Fallback for regular QComboBox + from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + list_quick_profiles, + ) + combo.blockSignals(True) combo.clear() - combo.addItems(list_profiles()) + quick_profiles = list_quick_profiles() + items = list(quick_profiles) + if active_profile and active_profile not in items: + items.insert(0, active_profile) + combo.addItems(items) + if active_profile: + idx = combo.findText(active_profile) + if idx >= 0: + combo.setCurrentIndex(idx) + if active_profile and active_profile not in quick_profiles: + combo.setToolTip("Active profile is not in quick select") + else: + combo.setToolTip("") combo.blockSignals(False) + ################################################################################ + # Dialog Popups + ################################################################################ + + @SafeSlot() + def show_workspace_manager(self): + """ + Show the workspace manager dialog. + """ + manage_action = self.toolbar.components.get_action("manage_workspaces").action + if self.manage_dialog is None or not self.manage_dialog.isVisible(): + self.manage_widget = WorkSpaceManager( + self, target_widget=self, default_profile=self._current_profile_name + ) + self.manage_dialog = QDialog(modal=False) + + self.manage_dialog.setWindowTitle("Workspace Manager") + self.manage_dialog.setMinimumSize(1200, 500) + self.manage_dialog.layout = QVBoxLayout(self.manage_dialog) + self.manage_dialog.layout.addWidget(self.manage_widget) + self.manage_dialog.finished.connect(self._manage_dialog_closed) + self.manage_dialog.show() + self.manage_dialog.resize(300, 300) + manage_action.setChecked(True) + else: + # If already open, bring it to the front + self.manage_dialog.raise_() + self.manage_dialog.activateWindow() + manage_action.setChecked(True) # keep it toggle + + def _manage_dialog_closed(self): + self.manage_widget.close() + self.manage_widget.deleteLater() + self.manage_dialog.deleteLater() + self.manage_dialog = None + self.toolbar.components.get_action("manage_workspaces").action.setChecked(False) + ################################################################################ # Mode Switching ################################################################################ @@ -913,6 +1002,15 @@ def cleanup(self): """ Cleanup the dock area. """ + # before cleanup save current profile (user copy) + name = getattr(self, "_current_profile_name", None) + if name: + us = open_user_settings(name) + self._write_snapshot_to_settings(us) + set_last_profile(name) + if self.manage_dialog is not None: + self.manage_dialog.reject() + self.manage_dialog = None self.delete_all() self.dark_mode_button.close() self.dark_mode_button.deleteLater() @@ -920,7 +1018,7 @@ def cleanup(self): super().cleanup() -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover import sys app = QApplication(sys.argv) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 47fe1ddd7..a49183ffe 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -1,21 +1,181 @@ +""" +Utilities for managing AdvancedDockArea profiles stored in INI files. + +Policy: +- All created/modified profiles are stored under the BEC settings root: /profiles/{default,user} +- Bundled read-only defaults are discovered in BW core states/default and plugin bec_widgets/profiles but never written to. +- Lookup order when reading: user → settings default → app or plugin bundled default. +""" + +from __future__ import annotations + import os +import shutil +from functools import lru_cache +from pathlib import Path +from typing import Literal +from bec_lib.client import BECClient +from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path +from pydantic import BaseModel, Field from PySide6QtAds import CDockWidget -from qtpy.QtCore import QSettings +from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt +from qtpy.QtGui import QPixmap 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") + +ProfileOrigin = Literal["module", "plugin", "settings", "unknown"] + + +def module_profiles_dir() -> str: + """Return the read-only module-bundled profiles directory (no writes here).""" + return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles") + + +@lru_cache(maxsize=1) +def _plugin_repo_root() -> Path | None: + try: + return Path(plugin_repo_path()) + except ValueError: + return None + + +@lru_cache(maxsize=1) +def _plugin_display_name() -> str | None: + repo_root = _plugin_repo_root() + if not repo_root: + return None + repo_name = repo_root.name + if repo_name: + return repo_name + try: + pkg = plugin_package_name() + except ValueError: + return None + return pkg.split(".")[0] if pkg else None + + +@lru_cache(maxsize=1) +def plugin_profiles_dir() -> str | None: + """Return the read-only plugin-bundled profiles directory if available.""" + repo_root = _plugin_repo_root() + if not repo_root: + return None + + candidates = [repo_root.joinpath("bec_widgets", "profiles")] + try: + package_root = repo_root.joinpath(*plugin_package_name().split(".")) + candidates.append(package_root.joinpath("bec_widgets", "profiles")) + except ValueError: + pass + + for candidate in candidates: + if candidate.is_dir(): + return str(candidate) + return None + + +def _settings_profiles_root() -> str: + """Return the writable profiles root provided by BEC client (or env fallback).""" + client = BECClient() + bec_widgets_settings = client._service_config.config.get("bec_widgets_settings") + bec_widgets_setting_path = ( + bec_widgets_settings.get("base_path") if bec_widgets_settings else None + ) + default_path = os.path.join(bec_widgets_setting_path, "profiles") + root = os.environ.get("BECWIDGETS_PROFILE_DIR", default_path) + os.makedirs(root, exist_ok=True) + return root + + +def default_profiles_dir() -> str: + path = os.path.join(_settings_profiles_root(), "default") + os.makedirs(path, exist_ok=True) + return path -def profiles_dir() -> str: - path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR) +def user_profiles_dir() -> str: + path = os.path.join(_settings_profiles_root(), "user") os.makedirs(path, exist_ok=True) return path -def profile_path(name: str) -> str: - return os.path.join(profiles_dir(), f"{name}.ini") +def default_profile_path(name: str) -> str: + return os.path.join(default_profiles_dir(), f"{name}.ini") + + +def user_profile_path(name: str) -> str: + return os.path.join(user_profiles_dir(), f"{name}.ini") + + +def module_profile_path(name: str) -> str: + return os.path.join(module_profiles_dir(), f"{name}.ini") + + +def plugin_profile_path(name: str) -> str | None: + directory = plugin_profiles_dir() + if not directory: + return None + return os.path.join(directory, f"{name}.ini") + + +def profile_origin(name: str) -> ProfileOrigin: + """ + Determine where a profile originates from. + + Returns: + ProfileOrigin: "module" for bundled BEC profiles, "plugin" for beamline plugin bundles, + "settings" for user-defined ones, and "unknown" if no backing files are found. + """ + if os.path.exists(module_profile_path(name)): + return "module" + plugin_path = plugin_profile_path(name) + if plugin_path and os.path.exists(plugin_path): + return "plugin" + if os.path.exists(user_profile_path(name)) or os.path.exists(default_profile_path(name)): + return "settings" + return "unknown" + + +def is_profile_read_only(name: str) -> bool: + """Return True when the profile originates from bundled module or plugin directories.""" + return profile_origin(name) in {"module", "plugin"} + + +def profile_origin_display(name: str) -> str | None: + """Return a human-readable label for the profile's origin.""" + origin = profile_origin(name) + if origin == "module": + return "BEC Widgets" + if origin == "plugin": + return _plugin_display_name() + if origin == "settings": + return "User" + return None + + +def delete_profile_files(name: str) -> bool: + """ + Delete the profile files from the writable settings directories. + + Removes both the user and default copies (if they exist) and clears the last profile + metadata when applicable. Returns True when at least one file was removed. + """ + if is_profile_read_only(name): + return False + + removed = False + for path in {user_profile_path(name), default_profile_path(name)}: + try: + os.remove(path) + removed = True + except FileNotFoundError: + continue + + if removed and get_last_profile() == name: + set_last_profile(None) + + return removed SETTINGS_KEYS = { @@ -23,29 +183,90 @@ def profile_path(name: str) -> str: "state": "mainWindow/State", "ads_state": "mainWindow/DockingState", "manifest": "manifest/widgets", - "readonly": "profile/readonly", + "created_at": "profile/created_at", + "is_quick_select": "profile/quick_select", + "screenshot": "profile/screenshot", + "screenshot_at": "profile/screenshot_at", + "last_profile": "app/last_profile", } def list_profiles() -> list[str]: - return sorted(os.path.splitext(f)[0] for f in os.listdir(profiles_dir()) if f.endswith(".ini")) + # Collect profiles from writable settings (default + user) + defaults = { + os.path.splitext(f)[0] for f in os.listdir(default_profiles_dir()) if f.endswith(".ini") + } + users = {os.path.splitext(f)[0] for f in os.listdir(user_profiles_dir()) if f.endswith(".ini")} + + # Also consider read-only defaults from core module and beamline plugin repositories + read_only_sources: dict[str, tuple[str, str]] = {} + sources: list[tuple[str, str | None]] = [ + ("module", module_profiles_dir()), + ("plugin", plugin_profiles_dir()), + ] + for origin, directory in sources: + if not directory or not os.path.isdir(directory): + continue + for filename in os.listdir(directory): + if not filename.endswith(".ini"): + continue + name, _ = os.path.splitext(filename) + read_only_sources.setdefault(name, (origin, os.path.join(directory, filename))) + for name, (_origin, src) in sorted(read_only_sources.items()): + # Ensure a copy in the settings default directory so existing code paths work unchanged + dst_default = default_profile_path(name) + if not os.path.exists(dst_default): + os.makedirs(os.path.dirname(dst_default), exist_ok=True) + shutil.copyfile(src, dst_default) + # Ensure a user copy exists to allow edits in the writable settings area + dst_user = user_profile_path(name) + if not os.path.exists(dst_user): + os.makedirs(os.path.dirname(dst_user), exist_ok=True) + shutil.copyfile(src, dst_user) + # Minimal metadata touch-up to align with existing expectations + s = open_user_settings(name) + if not s.value(SETTINGS_KEYS["created_at"], ""): + s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) -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) + defaults |= set(read_only_sources.keys()) + users |= set(read_only_sources.keys()) + # Return union of all discovered names + return sorted(defaults | users) -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_default_settings(name: str) -> QSettings: + return QSettings(default_profile_path(name), QSettings.IniFormat) -def open_settings(name: str) -> QSettings: - return QSettings(profile_path(name), QSettings.IniFormat) + +def open_user_settings(name: str) -> QSettings: + return QSettings(user_profile_path(name), QSettings.IniFormat) + + +def _app_settings() -> QSettings: + """Return app-wide settings file for AdvancedDockArea metadata.""" + return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat) + + +def get_last_profile() -> str | None: + """Return the last-used profile name if stored, else None.""" + s = _app_settings() + name = s.value(SETTINGS_KEYS["last_profile"], "", type=str) + return name or None + + +def set_last_profile(name: str | None) -> None: + """Persist the last-used profile name (or clear it if None).""" + s = _app_settings() + if name: + s.setValue(SETTINGS_KEYS["last_profile"], name) + else: + s.remove(SETTINGS_KEYS["last_profile"]) + + +def now_iso_utc() -> str: + return QDateTime.currentDateTimeUtc().toString(Qt.ISODate) def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: @@ -77,3 +298,197 @@ def read_manifest(settings: QSettings) -> list[dict]: ) settings.endArray() return items + + +def restore_user_from_default(name: str) -> None: + """Overwrite the user profile with the default baseline (keep default intact).""" + src = default_profile_path(name) + dst = user_profile_path(name) + if not os.path.exists(src): + return + preserve_quick_select = is_quick_select(name) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copyfile(src, dst) + s = open_user_settings(name) + if not s.value(SETTINGS_KEYS["created_at"], ""): + s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + if preserve_quick_select: + s.setValue(SETTINGS_KEYS["is_quick_select"], True) + + +def is_quick_select(name: str) -> bool: + """Return True if profile is marked to appear in quick-select combo.""" + s = ( + open_user_settings(name) + if os.path.exists(user_profile_path(name)) + else (open_default_settings(name) if os.path.exists(default_profile_path(name)) else None) + ) + if s is None: + return False + return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool) + + +def set_quick_select(name: str, enabled: bool) -> None: + """Set/unset the quick-select flag on the USER copy (creates it if missing).""" + s = open_user_settings(name) + s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled)) + + +def list_quick_profiles() -> list[str]: + """List only profiles that have quick-select enabled (user wins over default).""" + names = list_profiles() + return [n for n in names if is_quick_select(n)] + + +def _file_modified_iso(path: str) -> str: + try: + mtime = os.path.getmtime(path) + return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate) + except Exception: + return now_iso_utc() + + +def _manifest_count(settings: QSettings) -> int: + n = settings.beginReadArray(SETTINGS_KEYS["manifest"]) + settings.endArray() + return int(n or 0) + + +def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None: + data = settings.value(SETTINGS_KEYS["screenshot"], None) + if not data: + return None + + buf = None + if isinstance(data, QByteArray): + buf = data + elif isinstance(data, (bytes, bytearray, memoryview)): + buf = bytes(data) + elif isinstance(data, str): + try: + buf = QByteArray(data.encode("latin-1")) + except Exception: + buf = None + + if buf is None: + return None + + pm = QPixmap() + ok = pm.loadFromData(buf) + return pm if ok and not pm.isNull() else None + + +class ProfileInfo(BaseModel): + name: str + author: str = "BEC Widgets" + notes: str = "" + created: str = Field(default_factory=now_iso_utc) + modified: str = Field(default_factory=now_iso_utc) + is_quick_select: bool = False + widget_count: int = 0 + size_kb: int = 0 + user_path: str = "" + default_path: str = "" + origin: ProfileOrigin = "unknown" + is_read_only: bool = False + + +def get_profile_info(name: str) -> ProfileInfo: + """ + Return merged metadata for a profile as a validated Pydantic model. + Prefers the USER copy; falls back to DEFAULT if the user copy is missing. + """ + u_path = user_profile_path(name) + d_path = default_profile_path(name) + origin = profile_origin(name) + prefer_user = os.path.exists(u_path) + read_only = origin in {"module", "plugin"} + s = ( + open_user_settings(name) + if prefer_user + else (open_default_settings(name) if os.path.exists(d_path) else None) + ) + if s is None: + if origin == "module": + author = "BEC Widgets" + elif origin == "plugin": + author = _plugin_display_name() or "Plugin" + elif origin == "settings": + author = "User" + else: + author = "" + return ProfileInfo( + name=name, + author=author, + notes="", + created=now_iso_utc(), + modified=now_iso_utc(), + is_quick_select=False, + widget_count=0, + size_kb=0, + user_path=u_path, + default_path=d_path, + origin=origin, + is_read_only=read_only, + ) + + created = s.value(SETTINGS_KEYS["created_at"], "", type=str) or now_iso_utc() + src_path = u_path if prefer_user else d_path + modified = _file_modified_iso(src_path) + count = _manifest_count(s) + try: + size_kb = int(os.path.getsize(src_path) / 1024) + except Exception: + size_kb = 0 + settings_author = s.value("profile/author", "", type=str) or None + if origin == "module": + author = "BEC Widgets" + elif origin == "plugin": + author = _plugin_display_name() or "Plugin" + elif origin == "settings": + author = "User" + else: + author = settings_author or "user" + + return ProfileInfo( + name=name, + author=author, + notes=s.value("profile/notes", "", type=str) or "", + created=created, + modified=modified, + is_quick_select=is_quick_select(name), + widget_count=count, + size_kb=size_kb, + user_path=u_path, + default_path=d_path, + origin=origin, + is_read_only=read_only, + ) + + +def load_profile_screenshot(name: str) -> QPixmap | None: + """Load the stored screenshot pixmap for a profile from settings (user preferred).""" + u_path = user_profile_path(name) + d_path = default_profile_path(name) + s = ( + open_user_settings(name) + if os.path.exists(u_path) + else (open_default_settings(name) if os.path.exists(d_path) else None) + ) + if s is None: + return None + return _load_screenshot_from_settings(s) + + +def load_user_profile_screenshot(name: str) -> QPixmap | None: + """Load the screenshot from the user profile copy, if available.""" + if not os.path.exists(user_profile_path(name)): + return None + return _load_screenshot_from_settings(open_user_settings(name)) + + +def load_default_profile_screenshot(name: str) -> QPixmap | None: + """Load the screenshot from the default profile copy, if available.""" + if not os.path.exists(default_profile_path(name)): + return None + return _load_screenshot_from_settings(open_default_settings(name)) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py new file mode 100644 index 000000000..e9d60f3f0 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +from typing import Callable, Literal + +from qtpy.QtCore import Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import ( + QApplication, + QCheckBox, + QDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from bec_widgets import SafeSlot + + +class SaveProfileDialog(QDialog): + """Dialog for saving workspace profiles with quick select option.""" + + def __init__( + self, + parent: QWidget | None = None, + current_name: str = "", + current_profile_name: str = "", + *, + name_exists: Callable[[str], bool] | None = None, + profile_origin: ( + Callable[[str], Literal["module", "plugin", "settings", "unknown"]] | None + ) = None, + origin_label: Callable[[str], str | None] | None = None, + quick_select_checked: bool = False, + ): + super().__init__(parent) + self.setWindowTitle("Save Workspace Profile") + self.setModal(True) + self.resize(400, 160) + + self._name_exists = name_exists or (lambda _: False) + self._profile_origin = profile_origin or (lambda _: "unknown") + self._origin_label = origin_label or (lambda _: None) + self._current_profile_name = current_profile_name.strip() + self._previous_name_before_overwrite = current_name + self._block_name_signals = False + self._block_checkbox_signals = False + self.overwrite_existing = False + + 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) + + # Overwrite checkbox + self.overwrite_checkbox = QCheckBox("Overwrite current profile") + self.overwrite_checkbox.setEnabled(bool(self._current_profile_name)) + self.overwrite_checkbox.toggled.connect(self._on_overwrite_toggled) + layout.addWidget(self.overwrite_checkbox) + + # Quick-select checkbox + self.quick_select_checkbox = QCheckBox("Include in quick selection.") + self.quick_select_checkbox.setChecked(quick_select_checked) + layout.addWidget(self.quick_select_checkbox) + + # 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._on_name_changed) + self._update_save_button() + + @SafeSlot(bool) + def _on_overwrite_toggled(self, checked: bool): + if self._block_checkbox_signals: + return + if not self._current_profile_name: + return + + self._block_name_signals = True + if checked: + self._previous_name_before_overwrite = self.name_edit.text() + self.name_edit.setText(self._current_profile_name) + self.name_edit.selectAll() + else: + if self.name_edit.text().strip() == self._current_profile_name: + self.name_edit.setText(self._previous_name_before_overwrite or "") + self._block_name_signals = False + self._update_save_button() + + @SafeSlot(str) + def _on_name_changed(self, _: str): + if self._block_name_signals: + return + text = self.name_edit.text().strip() + if self.overwrite_checkbox.isChecked() and text != self._current_profile_name: + self._block_checkbox_signals = True + self.overwrite_checkbox.setChecked(False) + self._block_checkbox_signals = False + 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: + """Return the entered profile name.""" + return self.name_edit.text().strip() + + def is_quick_select(self) -> bool: + """Return whether the profile should appear in quick select.""" + return self.quick_select_checkbox.isChecked() + + def _generate_unique_name(self, base: str) -> str: + candidate_base = base.strip() or "profile" + suffix = "_custom" + candidate = f"{candidate_base}{suffix}" + counter = 1 + while self._name_exists(candidate) or self._profile_origin(candidate) != "unknown": + candidate = f"{candidate_base}{suffix}_{counter}" + counter += 1 + return candidate + + def accept(self): + name = self.get_profile_name() + if not name: + return + + self.overwrite_existing = False + origin = self._profile_origin(name) + if origin in {"module", "plugin"}: + source_label = self._origin_label(name) + if origin == "module": + provider = source_label or "BEC Widgets" + else: + provider = ( + f"the {source_label} plugin repository" + if source_label + else "the plugin repository" + ) + QMessageBox.information( + self, + "Read-only profile", + ( + f"'{name}' is a default profile provided by {provider} and cannot be overwritten.\n" + "Please choose a different name." + ), + ) + suggestion = self._generate_unique_name(name) + self._block_name_signals = True + self.name_edit.setText(suggestion) + self.name_edit.selectAll() + self._block_name_signals = False + self._block_checkbox_signals = True + self.overwrite_checkbox.setChecked(False) + self._block_checkbox_signals = False + return + if origin == "settings": + reply = QMessageBox.question( + self, + "Overwrite profile", + ( + f"A profile named '{name}' already exists.\n\n" + "Overwriting will update both the saved profile and its restore default.\n" + "Do you want to continue?" + ), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + suggestion = self._generate_unique_name(name) + self._block_name_signals = True + self.name_edit.setText(suggestion) + self.name_edit.selectAll() + self._block_name_signals = False + self._block_checkbox_signals = True + self.overwrite_checkbox.setChecked(False) + self._block_checkbox_signals = False + return + self.overwrite_existing = True + + super().accept() + + +class PreviewPanel(QGroupBox): + """Resizable preview pane that scales its pixmap with aspect ratio preserved.""" + + def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = None): + super().__init__(title, parent) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._original: QPixmap | None = pixmap if (pixmap and not pixmap.isNull()) else None + + layout = QVBoxLayout(self) + # layout.setContentsMargins(0,0,0,0) # leave room for group title and frame + + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setMinimumSize(360, 240) + self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + layout.addWidget(self.image_label, 1) + + if self._original: + self._update_scaled_pixmap() + else: + self.image_label.setText("No preview available") + self.image_label.setStyleSheet( + self.image_label.styleSheet() + "color: rgba(255,255,255,0.6); font-style: italic;" + ) + + def setPixmap(self, pixmap: QPixmap | None): + self._original = pixmap if (pixmap and not pixmap.isNull()) else None + if self._original: + self.image_label.setText("") + self._update_scaled_pixmap() + else: + self.image_label.setPixmap(QPixmap()) + self.image_label.setText("No preview available") + + def resizeEvent(self, event): + super().resizeEvent(event) + if self._original: + self._update_scaled_pixmap() + + def _update_scaled_pixmap(self): + if not self._original: + return + size = self.image_label.size() + if size.width() <= 0 or size.height() <= 0: + return + scaled = self._original.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.image_label.setPixmap(scaled) + + +class RestoreProfileDialog(QDialog): + """ + Confirmation dialog that previews the current profile screenshot against the default baseline. + """ + + def __init__( + self, parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None + ): + super().__init__(parent) + self.setWindowTitle("Restore Profile to Default") + self.setModal(True) + self.resize(880, 480) + + layout = QVBoxLayout(self) + + info_label = QLabel( + "Restoring will discard your custom layout and replace it with the default profile." + ) + info_label.setWordWrap(True) + layout.addWidget(info_label) + + preview_row = QHBoxLayout() + layout.addLayout(preview_row) + + current_preview = PreviewPanel("Current", current_pixmap, self) + default_preview = PreviewPanel("Default", default_pixmap, self) + + # Equal expansion left/right + preview_row.addWidget(current_preview, 1) + + arrow_label = QLabel("\u2192") + arrow_label.setAlignment(Qt.AlignCenter) + arrow_label.setStyleSheet("font-size: 32px; padding: 0 16px;") + arrow_label.setMinimumWidth(40) + arrow_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + preview_row.addWidget(arrow_label) + + preview_row.addWidget(default_preview, 1) + + # Enforce equal stretch for both previews + preview_row.setStretch(0, 1) + preview_row.setStretch(1, 0) + preview_row.setStretch(2, 1) + + warn_label = QLabel( + "This action cannot be undone. Do you want to restore the default layout now?" + ) + warn_label.setWordWrap(True) + layout.addWidget(warn_label) + + btn_row = QHBoxLayout() + btn_row.addStretch(1) + restore_btn = QPushButton("Restore") + restore_btn.setDefault(True) + cancel_btn = QPushButton("Cancel") + restore_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + btn_row.addWidget(restore_btn) + btn_row.addWidget(cancel_btn) + layout.addLayout(btn_row) + + # Make the previews take most of the vertical space on resize + layout.setStretch(0, 0) # info label + layout.setStretch(1, 1) # preview row + layout.setStretch(2, 0) # warning label + layout.setStretch(3, 0) # buttons + + @staticmethod + def confirm( + parent: QWidget | None, current_pixmap: QPixmap | None, default_pixmap: QPixmap | None + ) -> bool: + dialog = RestoreProfileDialog(parent, current_pixmap, default_pixmap) + return dialog.exec() == QDialog.Accepted diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py new file mode 100644 index 000000000..936227479 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +from functools import partial + +from bec_qthemes import material_icon +from qtpy.QtCore import Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import ( + QAbstractItemView, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QStyledItemDelegate, + QTableWidget, + QTableWidgetItem, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget, SafeSlot +from bec_widgets.utils.colors import apply_theme, get_accent_colors +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + delete_profile_files, + get_profile_info, + is_quick_select, + list_profiles, + load_profile_screenshot, + set_quick_select, +) + + +class WorkSpaceManager(BECWidget, QWidget): + RPC = False + PLUGIN = False + COL_ACTIONS = 0 + COL_NAME = 1 + COL_AUTHOR = 2 + HEADERS = ["Actions", "Profile", "Author"] + + def __init__( + self, parent=None, target_widget=None, default_profile: str | None = None, **kwargs + ): + super().__init__(parent=parent, **kwargs) + self.target_widget = target_widget + self.accent_colors = get_accent_colors() + self._init_ui() + if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"): + self.target_widget.profile_changed.connect(self.on_profile_changed) + if default_profile is not None: + self._select_by_name(default_profile) + self._show_profile_details(default_profile) + + def _init_ui(self): + self.root_layout = QHBoxLayout(self) + self.splitter = QSplitter(Qt.Horizontal, self) + self.root_layout.addWidget(self.splitter) + + # Init components + self._init_profile_table() + self._init_profile_details_tree() + self._init_screenshot_preview() + + # Build two-column layout + left_col = QVBoxLayout() + left_col.addWidget(self.profile_table, 1) + left_col.addWidget(self.profile_details_tree, 0) + + self.save_profile_button = QPushButton("Save current layout as new profile", self) + self.save_profile_button.clicked.connect(self.save_current_as_profile) + left_col.addWidget(self.save_profile_button) + self.save_profile_button.setEnabled(self.target_widget is not None) + + # Wrap left widgets into a panel that participates in splitter sizing + left_panel = QWidget(self) + left_panel.setLayout(left_col) + left_panel.setMinimumWidth(220) + + # Make the screenshot preview expand to fill remaining space + self.screenshot_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.right_box = QGroupBox("Profile Screenshot Preview", self) + right_col = QVBoxLayout(self.right_box) + right_col.addWidget(self.screenshot_label, 1) + + self.splitter.addWidget(left_panel) + self.splitter.addWidget(self.right_box) + self.splitter.setStretchFactor(0, 0) + self.splitter.setStretchFactor(1, 1) + self.splitter.setSizes([350, 650]) + + def _init_profile_table(self): + self.profile_table = QTableWidget(self) + self.profile_table.setColumnCount(len(self.HEADERS)) + self.profile_table.setHorizontalHeaderLabels(self.HEADERS) + self.profile_table.setAlternatingRowColors(True) + self.profile_table.verticalHeader().setVisible(False) + + # Enforce row selection, single-select, and disable edits + self.profile_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.profile_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.profile_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + + # Ensure the table expands to use vertical space in the left panel + self.profile_table.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + header = self.profile_table.horizontalHeader() + header.setStretchLastSection(False) + header.setDefaultAlignment(Qt.AlignCenter) + + class _CenterDelegate(QStyledItemDelegate): + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + option.displayAlignment = Qt.AlignCenter + + self.profile_table.setItemDelegate(_CenterDelegate(self.profile_table)) + + header.setSectionResizeMode(self.COL_ACTIONS, QHeaderView.ResizeToContents) + header.setSectionResizeMode(self.COL_NAME, QHeaderView.Stretch) + header.setSectionResizeMode(self.COL_AUTHOR, QHeaderView.ResizeToContents) + self.render_table() + self.profile_table.itemSelectionChanged.connect(self._on_table_selection_changed) + self.profile_table.cellClicked.connect(self._on_cell_clicked) + + def _init_profile_details_tree(self): + self.profile_details_tree = QTreeWidget(self) + self.profile_details_tree.setHeaderLabels(["Field", "Value"]) + # Keep details compact so the table can expand + self.profile_details_tree.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + + def _init_screenshot_preview(self): + self.screenshot_label = QLabel(self) + self.screenshot_label.setMinimumHeight(160) + self.screenshot_label.setAlignment(Qt.AlignCenter) + + def render_table(self): + self.profile_table.setRowCount(0) + for profile in list_profiles(): + self._add_profile_row(profile) + + def _add_profile_row(self, name: str): + row = self.profile_table.rowCount() + self.profile_table.insertRow(row) + + actions_items = QWidget(self) + actions_items.profile_name = name + actions_items_layout = QHBoxLayout(actions_items) + actions_items_layout.setContentsMargins(0, 0, 0, 0) + + info = get_profile_info(name) + + # Flags + is_active = ( + self.target_widget is not None + and getattr(self.target_widget, "_current_profile_name", None) == name + ) + quick = info.is_quick_select + is_read_only = info.is_read_only + + # Play (green if active) + self._make_action_button( + actions_items, + "play_circle", + "Switch to this profile", + self.switch_profile, + filled=is_active, + color=(self.accent_colors.success if is_active else None), + ) + + # Quick-select (yellow if enabled) + self._make_action_button( + actions_items, + "star", + "Include in quick selection", + self.toggle_quick_select, + filled=quick, + color=(self.accent_colors.warning if quick else None), + ) + + # Delete (red, disabled when read-only) + delete_button = self._make_action_button( + actions_items, + "delete", + "Delete this profile", + self.delete_profile, + color=self.accent_colors.emergency, + ) + if is_read_only: + delete_button.setEnabled(False) + delete_button.setToolTip("Bundled profiles are read-only and cannot be deleted.") + + actions_items_layout.addStretch() + + self.profile_table.setCellWidget(row, self.COL_ACTIONS, actions_items) + self.profile_table.setItem(row, self.COL_NAME, QTableWidgetItem(name)) + self.profile_table.setItem(row, self.COL_AUTHOR, QTableWidgetItem(info.author)) + + def _make_action_button( + self, + parent: QWidget, + icon_name: str, + tooltip: str, + slot: callable, + *, + filled: bool = False, + color: str | None = None, + ): + button = QToolButton(parent=parent) + button.setIcon(material_icon(icon_name, filled=filled, color=color)) + button.setToolTip(tooltip) + button.clicked.connect(partial(slot, parent.profile_name)) + parent.layout().addWidget(button) + return button + + def _select_by_name(self, name: str) -> None: + for row in range(self.profile_table.rowCount()): + item = self.profile_table.item(row, self.COL_NAME) + if item and item.text() == name: + self.profile_table.selectRow(row) + break + + def _current_selected_profile(self) -> str | None: + rows = self.profile_table.selectionModel().selectedRows() + if not rows: + return None + row = rows[0].row() + item = self.profile_table.item(row, self.COL_NAME) + return item.text() if item else None + + def _show_profile_details(self, name: str) -> None: + info = get_profile_info(name) + self.profile_details_tree.clear() + entries = [ + ("Name", info.name), + ("Author", info.author or ""), + ("Created", info.created or ""), + ("Modified", info.modified or ""), + ("Quick select", "Yes" if info.is_quick_select else "No"), + ("Widgets", str(info.widget_count)), + ("Size (KB)", str(info.size_kb)), + ("User path", info.user_path or ""), + ("Default path", info.default_path or ""), + ] + for k, v in entries: + self.profile_details_tree.addTopLevelItem(QTreeWidgetItem([k, v])) + self.profile_details_tree.expandAll() + + # Render screenshot preview from profile INI + pm = load_profile_screenshot(name) + if pm is not None and not pm.isNull(): + scaled = pm.scaled( + self.screenshot_label.width() or 800, + self.screenshot_label.height() or 450, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + self.screenshot_label.setPixmap(scaled) + else: + self.screenshot_label.setPixmap(QPixmap()) + + @SafeSlot() + def _on_table_selection_changed(self): + name = self._current_selected_profile() + if name: + self._show_profile_details(name) + + @SafeSlot(int, int) + def _on_cell_clicked(self, row: int, column: int): + item = self.profile_table.item(row, self.COL_NAME) + if item: + self._show_profile_details(item.text()) + + ################################################## + # Public Slots + ################################################## + @SafeSlot(str) + def on_profile_changed(self, name: str): + """Keep the manager in sync without forcing selection to the active profile.""" + selected = self._current_selected_profile() + self.render_table() + if selected: + self._select_by_name(selected) + self._show_profile_details(selected) + + @SafeSlot(str) + def switch_profile(self, profile_name: str): + self.target_widget.load_profile(profile_name) + try: + self.target_widget.toolbar.components.get_action( + "workspace_combo" + ).widget.setCurrentText(profile_name) + except Exception as e: + print(f"Warning: Could not update workspace combo box. {e}") + pass + self.render_table() + self._select_by_name(profile_name) + self._show_profile_details(profile_name) + + @SafeSlot(str) + def toggle_quick_select(self, profile_name: str): + enabled = is_quick_select(profile_name) + set_quick_select(profile_name, not enabled) + self.render_table() + if self.target_widget is not None: + self.target_widget._refresh_workspace_list() + name = self._current_selected_profile() + if name: + self._show_profile_details(name) + + @SafeSlot() + def save_current_as_profile(self): + if self.target_widget is None: + QMessageBox.information( + self, + "Save Profile", + "No workspace is associated with this manager. Attach a workspace to save profiles.", + ) + return + + self.target_widget.save_profile() + # AdvancedDockArea will emit profile_changed which will trigger table refresh, + # but ensure the UI stays in sync even if the signal is delayed. + self.render_table() + current = getattr(self.target_widget, "_current_profile_name", None) + if current: + self._select_by_name(current) + self._show_profile_details(current) + + @SafeSlot(str) + def delete_profile(self, profile_name: str): + info = get_profile_info(profile_name) + if info.is_read_only: + QMessageBox.information( + self, "Delete Profile", "This profile is read-only and cannot be deleted." + ) + return + + reply = QMessageBox.question( + self, + "Delete Profile", + ( + f"Delete the profile '{profile_name}'?\n\n" + "This will remove both the user and default copies." + ), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + + try: + removed = delete_profile_files(profile_name) + except OSError as exc: + QMessageBox.warning( + self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}" + ) + return + + if not removed: + QMessageBox.information( + self, "Delete Profile", "No writable profile files were found to delete." + ) + return + + if self.target_widget is not None: + if getattr(self.target_widget, "_current_profile_name", None) == profile_name: + self.target_widget._current_profile_name = None + if hasattr(self.target_widget, "_refresh_workspace_list"): + self.target_widget._refresh_workspace_list() + + self.render_table() + remaining_profiles = list_profiles() + if remaining_profiles: + next_profile = remaining_profiles[0] + self._select_by_name(next_profile) + self._show_profile_details(next_profile) + else: + self.profile_details_tree.clear() + self.screenshot_label.setPixmap(QPixmap()) + + def resizeEvent(self, event): + super().resizeEvent(event) + name = self._current_selected_profile() + if not name: + return + pm = load_profile_screenshot(name) + if pm is None or pm.isNull(): + return + scaled = pm.scaled( + self.screenshot_label.width() or 800, + self.screenshot_label.height() or 450, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + self.screenshot_label.setPixmap(scaled) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini b/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini deleted file mode 100644 index 6188162c0..000000000 --- a/bec_widgets/widgets/containers/advanced_dock_area/states/user/test.ini +++ /dev/null @@ -1,234 +0,0 @@ -[BECMainWindowNoRPC.AdvancedDockArea] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -baseSize=@Size(0 0) -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -enabled=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 29 2075 974) -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -lock_workspace=false -maximumSize=@Size(16777215 16777215) -minimumSize=@Size(0 0) -mode=developer -mouseTracking=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -statusTip= -styleSheet= -tabletTracking=false -toolTip= -toolTipDuration=-1 -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle=Advanced Dock Area - -[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.BECQueue.dockWidgetScrollArea.qt_scrollarea_viewport.BECQueue] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -baseSize=@Size(0 0) -compact_view=false -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -enabled=true -expand_popup=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 0 1252 897) -hide_toolbar=false -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -label=BEC Queue -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -maximumSize=@Size(16777215 16777215) -minimumSize=@Size(0 0) -mouseTracking=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -statusTip= -styleSheet= -tabletTracking=false -toolTip= -toolTipDuration=-1 -tooltip=BEC Queue status -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle= - -[BECMainWindowNoRPC.AdvancedDockArea.CDockManager.ads%3A%3ACDockSplitter.ads%3A%3ACDockAreaWidget.Waveform.dockWidgetScrollArea.qt_scrollarea_viewport.Waveform] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -auto_range_x=true -auto_range_y=true -baseSize=@Size(0 0) -color_palette=plasma -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -curve_json=[] -enable_fps_monitor=false -enable_popups=true -enable_side_panel=false -enable_toolbar=true -enabled=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 0 798 897) -inner_axes=true -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -legend_label_size=9 -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -lock_aspect_ratio=false -max_dataset_size_mb=10 -maximumSize=@Size(16777215 16777215) -minimal_crosshair_precision=3 -minimumSize=@Size(0 0) -mouseTracking=false -outer_axes=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -skip_large_dataset_check=false -skip_large_dataset_warning=false -statusTip= -styleSheet= -tabletTracking=false -title= -toolTip= -toolTipDuration=-1 -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle= -x_entry= -x_grid=false -x_label= -x_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) -x_log=false -x_mode=auto -y_grid=false -y_label= -y_limits=@Variant(\0\0\0\x1a\0\0\0\0\0\0\0\0?\xf0\0\0\0\0\0\0) -y_log=false - -[BECMainWindowNoRPC.AdvancedDockArea.ModularToolBar.QWidget.DarkModeButton] -acceptDrops=false -accessibleDescription= -accessibleIdentifier= -accessibleName= -autoFillBackground=false -baseSize=@Size(0 0) -contextMenuPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\x66\x80\x4\x95[\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x14Qt.ContextMenuPolicy\x94\x93\x94\x8c\x12\x44\x65\x66\x61ultContextMenu\x94\x86\x94R\x94.) -cursor=@Variant(\0\0\0J\0\0) -dark_mode_enabled=false -enabled=true -focusPolicy=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0U\x80\x4\x95J\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\xeQt.FocusPolicy\x94\x93\x94\x8c\aNoFocus\x94\x86\x94R\x94.) -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -geometry=@Rect(0 0 40 40) -inputMethodHints=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.InputMethodHint\x94\x93\x94\x8c\aImhNone\x94\x86\x94R\x94.) -layoutDirection=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.LayoutDirection\x94\x93\x94\x8c\vLeftToRight\x94\x86\x94R\x94.) -locale=@Variant(\0\0\0\x12\0\0\0\n\0\x65\0n\0_\0\x43\0H) -maximumSize=@Size(40 40) -minimumSize=@Size(40 40) -mouseTracking=false -palette=@Variant(\0\0\0\x44\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1??MMQQWW\0\0\x1\x1\x66\x66MMQQWW\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0\x1\x1\xff\xff\xbf\xbf\xbf\xbf\xbf\xbf\0\0\x1\x1\xff\xff\xa9:\xa9:\xa9:\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xff\xff\xff\xff\xff\xff\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\xf8\xf8\xf9\xf9\xfa\xfa\0\0\x1\x1\xff\xff\0\0\0\0\0\0\0\0\x1\x1\x7f\x7f\x61\x61\x9e\x9e\xef\xef\0\0\x1\x1\xff\xffMMQQWW\0\0\x1\x1\xff\xff\x1a\x1ass\xe8\xe8\0\0\x1\x1\xff\xff\x66\x66\0\0\x98\x98\0\0\x1\x1\xff\xff\xf5\xf5\xf5\xf5\xf5\xf5\0\0) -sizeIncrement=@Size(0 0) -sizePolicy=@Variant(\0\0\0K\0\0\0U) -statusTip= -styleSheet= -tabletTracking=false -toolTip= -toolTipDuration=-1 -updatesEnabled=true -visible=true -whatsThis= -windowFilePath= -windowIcon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\x1\x81iCCPsRGB IEC61966-2.1\0\0(\x91u\x91\xb9KCA\x10\x87\xbf\x1c\x1ehB\x4-,,\x82\x44\xab(^\x4m\x4\x13\x44\x85 !F\xf0j\x92g\xe!\xc7\xe3\xbd\x88\x4[\xc1\x36\xa0 \xdax\x15\xfa\x17h+X\v\x82\xa2\bb\x9dZ\xd1\x46\xc3s\x9e\x11\x12\xc4\xcc\x32;\xdf\xfevg\xd8\x9d\x5k$\xad\x64t\xfb\0\x64\xb2y-<\xe5w/,.\xb9\x9bJ4\xd0\x88\x13;#QEW'B\xa1 u\xed\xe3\x1\x8b\x19\xef\xfa\xccZ\xf5\xcf\xfdk\xad\xabq]\x1K\xb3\xf0\xb8\xa2jy\xe1i\xe1\xe0\x46^5yW\xb8\x43IEW\x85\xcf\x85\xbd\x9a\\P\xf8\xde\xd4\x63\x15.\x99\x9c\xac\xf0\x97\xc9Z$\x1c\0k\x9b\xb0;Y\xc3\xb1\x1aVRZFX^\x8e'\x93^W~\xef\x63\xbe\xc4\x11\xcf\xce\xcfI\xec\x16\xef\x42'\xcc\x14~\xdc\xcc\x30I\0\x1f\x83\x8c\xc9\xec\xa3\x8f!\xfa\x65\x45\x9d\xfc\x81\x9f\xfcYr\x92\xab\xc8\xacR@c\x8d$)\xf2xE]\x97\xeaq\x89\t\xd1\xe3\x32\xd2\x14\xcc\xfe\xff\xed\xab\x9e\x18\x1e\xaaTw\xf8\xa1\xe1\xc5\x30\xdez\xa0i\a\xca\x45\xc3\xf8<6\x8c\xf2\t\xd8\x9e\xe1*[\xcd\xcf\x1d\xc1\xe8\xbb\xe8\xc5\xaa\xe6\x39\x4\xd7\x16\\\\W\xb5\xd8\x1e\\nC\xe7\x93\x1a\xd5\xa2?\x92M\xdc\x9aH\xc0\xeb\x19\x38\x17\xa1\xfd\x16Z\x96+=\xfb\xdd\xe7\xf4\x11\"\x9b\xf2U7\xb0\x7f\0\xbdr\xde\xb5\xf2\r+\xf4g\xcbwA\x5\xc7\0\0\0\tpHYs\0\0=\x84\0\0=\x84\x1\xd5\xac\xaft\0\0\x6\xdaIDATX\x85\xed\x99mp\x15W\x19\xc7\x7fgor\xc3\xbd\xb9I/\x91$\xb7XB^J\x81\x84*F\xd3\x32\x86ON\xd5\nh\xb4\x36\x43p\xa0\xadT3\x9d\x86\xfa\xc1R-\x1a\x64\xca@\xeb\xe0\x94\xce\xd8\xb4\xe\x38\x3\xbe\xd1J\x98I\xc6\t\x16\x11\x61\x98\x90\x12\xa0)\x4\xa5-\xa0\xd4\xb6\xa9\x91\x4\b$$$\xdc\xdd=\xc7\xf\xbb\xf7}s\xefM\x88\xce\x38\xc3\x7f\x66gw\xcfy\xces\xfe\xe7\x7f\x9e}\xce\xd9]\xb8\x8d\xdb\xf8\xff\x86H\xc7\x66\xfb\xa7\x1f\x99\x93\xe5r\xd7(\xb4O\xe9(\x7fP\xa1\x19h\x18\n\x82\x80\x1\x18\x12\fD\xf8^W\x1a:`(\xd0\xb1\xe\x43\n\xeb\f\x4\x95\xc0\x4\x19\x84k\x86\x14\x7f\xbd\xa1\x8f\xfd\xe1\xf4\xfb\xbf\xfc;\xa0&M\xf8\x95\x5\x35\xb3\n\xb2\xf2\xb7*\xa1=\xa4\x83\x66u.\xd0U\x88\x94M\xc0>[\xe5V\xbdn\xd7\x1bv\xb9\x8e \xdc><\x90\x88\xaf\xa0\x12\xd2P\xb2\xe5\xfa\xf5K\xdf\xbf\xd0\xfb\xfb\x9e\xf1\x38i\xe3U\xec\xacX^U\xe4\xc9?\x91\xa1\x89\x87\x93\xd9M\b!y\x1c\x34\x14\xa0!\xb4\x87\xa7\xf9\n\xdf*-}\xacj<\x17\x8e\x44\x9a\xe6/\x99]\xe8\xcdi\x13\x88\xc0\x94\x10\r1U\xb1\xb7\x8e\x96\x42\x14\x66\x64\xdc\xd1\x16\b|\xbd\xd8\xa9\xde\x89\xb0\xab\xd4[\xf8\x92&D\xe1-qt\xea\xcc\xa5QQu7\xbe\\OR;!D\xa1\x37\xe7\xae\xad\x80+\xa1.\xbe\xe0\x17\xf3\x97.\xb8\xc7w\xe7i]\b-\x14_\xae|?\xca\x33\r\x13+\xf6\f\xac\xd8\x8c\xbf\xbe\xd2{\x95\xb1\x9b\x86\x63\fKM\xa3~\xf3*\x16/\xab\xa2\xe7\xc2\x45~\xb8\xba\x89\xfe\xcb\xd7\x93=\xfr\xe4\xc6\xbf\x16\xf6}\xbc\xe7o\xd1\xfc\x32\xe2\t\xcf\xf2\xe4\xd5 \"\xcag\x97\x4X\xf8\xf2\x1a\x84\x96:\x8c/\x1c=K\xebO^O(\xd7\\\x1a\xdf\xdd\xbc\x8a\xea\xa5Vh\xce*\v\xf0\xd3\x1dkX\xbb\xba\x89\xbeK\xd7\x63l\xa3\xc2[sg\xe5\x7f\r\x88!\x1c\xcf\x42\xcb\xd4\\\xf7\x46\x17\x64\xfa}\x16Y\xa5PI\xe\x80\xbc\xd9\xf9\x31\xcf\x93\0\x34-BV)\xc5\xa9\x8e\xf7\x30\f\x93\xa2\xb2\0?\xdb\xb1\x86\xbc\x19\x39\x31\x4\x62\xa6\\h\xf7\xc6s\x8cWX\b\x81?^\xa1\xd0\xc8\xf|s\x13\xba\x94\x91\xa9\xc6\x9a\xc2\x39\xcb\xaaXT\xff`B\x87\xc2%X\xbdi\x15\xf7\xdb\xca\x1e\xd8\xdd\xc1\xb6\xcd{\xb8\xef\x8b\vyz\xcb\xa3\xcc.\v\xf0\xd2\xce\x35<\xf5\xedW\x12\x94\xb6\x31=~\f\xf1\n\v\xa5\xc6Oa\xc6X\x10\x63L\xb7\xcf\x41\x8cQ\x1d},\x88\xd4\xcd\x4[M\xd3xt\xd3#Qd\x8f\xb0\xe3\xf9=(\x5\x1d\xfb\xbb\xd9\xf2\x83\xdf`\x18&\xc5\x65\x1^\xde\xd9\xc0'\xe2\x94\xb6U\xd2R\x11\x46\xa1\xc6M:\xe9,\x8b!\xc3o=\xb7\x92\xfbl\xb2\aw\x1f\xe1\xd7\x9b\x9bQ2\x12\x30G\xf6\x9f\xe2\x85g\"\xa4_\xfdU\x3\xb9\xb9\xde\xe4\xcb\x9c\x13\xe1\xa9\x80\x10P\xf9`%\0\xc3\xd7\x46hm\xda\x8bRDFl\x9f\xdb\xfts\xf2\xd8y\0J\xca\x2\xcc\x99;3\xa5(SNX\x1J\xc2\x9e\xe7\x9b\x91R\xe2\xf3g\xb3v[\x3\xbe\\oL\n\x10\x42\xf0\xbd\xc6Z\xaa\xaa\xe7\x1\xb0\xaf\xad\x8b\xee\xb7\xdfO\xe9\x7f\xca\t\x87\x14\xeal=\xcak\x1b_GJIqy\x11\xcfno \xfb\xe\xafM\x16\x9el\xac\xe5\xabu\x8b\x11\x42\xb0\xbf\xad\x8b\x8d\xebvaJ\x99\xd2\x7f\x42\x1e\x8e z\xe-\x14V\x97\x63H\x85\t\x98\xd6n\vSA^\xa9\xf3\n~\xb4\xa5\x13S\xc1\xaa\r+(\xa9(\xa2q{\x3\xcf\xd5\xbf\xca\xf2\xa7\x96\xb2\xa4n1\0\a\xda\xba\xd8\xb4n\x17\xa6\xa9\xc2\xbdN\x92p,Y!\x4\x95?^\x91\xd4\x99\x32\x63\x15R@Gk'&\xf0\xd8\x86\x15\x94V\x14\xd1\xf4\xc7\xf5\xe4\xf8\xb3\x1\xf8\xcb\xde.^\xf8\xd1.\xa4\x94\t\xfdM\x82\xb0\x85\x1b\x1f]b\xe4\xc3>\\>\xf\xa8\x88\x2N\xe7\xf3\x87\xcf\x38\xfaho\xe9\xc4\x44\xf0\xf8\x86\xba\x30\xd9\x43{\xbb\xd8\xb2\xeewae\xd3\x45J\xc2\xc1+\x83\x9cx\xe2\xe7\x8e{X\xa7\xfd\xf0xJ\x1dn\xe9\x44*X\xddXK\xfb\xbeSl]\xff\x1aRN\x8clJ\xc2i\xe7\xdd\x34q\xa8\xb5\x93?\xb7\x1e#\xa8@&\x19\\2$\xcd\x12\x13\x1f\x7fj(uk\x8eS\x86\x84\xdb\xef\x63\xd1\xc6\xc7\xc9\xf4Y\xab\x90\xf3!P\nN\xee=N\xc7\xee\xf6p\xdb\x65O.\xa1\xbc\xba<\xd6VE\xb5\xb1\xcb\xfa\xfb\ai|\xe6\xb7\xe8\x63zJ\xcdS\x12\xf6\xdf\x33\x8b\xc0\xa2\x8aTf\x96\x33o\x16oF\x11\xae~\xe8\xf3\xf8\v\x1c\xf7R1\x98\xf\xdc=w&\xdd\xa7?L\xddGZL\0\xa5\x14o\xbd\xd8\x8ci\xe7\x61I$\xf\xdf\xf5\xd9\x39\xcc{\xa0\x92\x8cL\x97\xe3lw\xbe\xd1\xc5\xf9\xee\x7f\"\x1\x33\xaa\x9d\xcb\x9d\x41\xfd\xda\x1a\x84\x10\x64\x66$\xbc\\L\x9cp\xcc\xf4(8\xd7|\x98\xa0T\tYB\x2\xf3\x1e\xa8Llc\xe3\xdd\xe3\xe7\x38\xd4r,\xe1\xad\xd9\xe5\xc9\xa2~mMZDC\xf8\xdf>tS\x90v\xa6t/\x91r\x80i(pK\xdb\xcb\x89\n2\xd5y\xdb\t\xf1\x84\x95\xb0\x42\xd2\xba\x99\x84\xc3[\r\xa3\xd8w:\x95\xe0.Aa\xa9\xd4\xe0\x64;K\xdc\xdf%1L\xa3J\xa1\x6\xe3\xad\xe3\xb3\x84\xbc\x61\x1a\xef\xba]\xee\x44O\x2\x96\x1f|\x91\xe8\x31\x87.]\xee\x8c\x84\xb2h\xac|\xb6\x96\xba\xa7\xbf\xe1P\x9f\x62x\xd2x'\x15\x61ur\xb0\xe7\x8d/\xcc\x98\xbb\x1e\x81&\0}x\x14\xa5\x14\x42\b\xdc\x39\xde\xa4\xfe\a\xff=\x10\x43\x61th\x14\x7f\x81\x9f,\x8f\x9b,\x8f\x83\b6LS\xd2\xdfw-\x81\xee\xe8\xc8\a\xfb\x88\nQp\x1e\xa2\xaf\xe5\x33+\x9b\xbd\x99\x9e\xaf\x84\xf2mNy\t\"\xdb\x13N\xf8\x86\x9d\xfc\x8d\xe8{\x5=\xe7>f\xe8\xeaH\xf8\xcb\x8f\xaf`:3J\x2Q\xed\xac\x85#\xbe\xdd\xe5\x81\x61\xde;\xdb\x1b\xf3\xb5\xe8\xa6\x31\xfa\xa7\xf.l\xab\x5\x86S\x11\xd6\xea\xee\\P\xb9\xf2\x93\xf7\xefSB\x9b\x11\xbb\x8d\xfc\xef}n\x8d\xf6\x15T\xf2\xf2\xc5\x8b\xedK\x86\x86\xba\xdf\x8eW\xd8i=Tg\x86\xfb\a\xf2\\\xdew\x8a\xb3\xf3\xbe\x84\xd0\xbc\x12\x81\xb4[:_\xa7\xaaO\xdf\xd6P\xf2\xca\xd5\xc1\x33\xdf\x19\x18\x38v\x14k\xdc\x31\x18o\x1\x37N\f\xf5|4l\x8c\x1e.\xf1\x16\x14\xb9]\x99%\x12\xc4\xe4\t\xa7\x35 \x19\xd4G\xe\xf6\xf6\x1dy\xe2\xf2\xc0\x89\x37\x81Q'b\xe9\xec\xe6\xa6\xd7\xce\xfc\xdc\xc2J\x7f\xf1\x97\xbd\xaei\xf3\xa4\x10\xb9\x6\x42\xe8\baH\xc2\xbf\0\x42_2#\xf7\xf6/\x3I\xccg-\x83\xf0/\x5\x65\xa2\xd4M%\x87\xc6\x8c\x9bg/\r\xfe\x63\x7f\xef\xc0\xf1\xd3\xc0U'e\xd3%\x1c\x82\v\xc8\xb4\xf\x17S\xb7\xa4[\x1b\x38\xfb\xaf\x81}}\x1b\xb7\x31\x11\xfc\a\x15\xfav.&\xc5\xfc~\0\0\0\0IEND\xae\x42`\x82)" -windowIconText= -windowModality=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0Y\x80\x4\x95N\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x11Qt.WindowModality\x94\x93\x94\x8c\bNonModal\x94\x86\x94R\x94.) -windowModified=false -windowOpacity=1 -windowTitle= - -[BECMainWindowNoRPC.AdvancedDockArea.dockSettingsAction] -autoRepeat=true -checkable=false -checked=false -enabled=true -font=@Variant(\0\0\0@\0\0\0$\0.\0\x41\0p\0p\0l\0\x65\0S\0y\0s\0t\0\x65\0m\0U\0I\0\x46\0o\0n\0t@*\0\0\0\0\0\0\xff\xff\xff\xff\x5\x1\0\x32\x10) -icon="@Variant(\0\0\0\x45\0\0\0\x1\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0,\0\0\0,\b\x6\0\0\0\x1e\x84Z\x1\0\0\0\tpHYs\0\0\v\x13\0\0\v\x13\x1\0\x9a\x9c\x18\0\0\x4\xc9IDATX\x85\xed\x99]lSu\x18\xc6\x7f\xef\xe9\x6\x85\xb0\xa0\xc1\x8b\xe9\x2\x46\xe3`\xa0!\xa0\x88\x6\x35*\x9a\xb9\x96\xb6\v\xed*\x82\x80\x46\x12\xe3\x95\x1a\x14I\xc4\x8b\xde(~$&&\x10/d\x17\x80\xdf]7i\xb7u \xc1%\x9a\x18\xd4\xa0\xf1\x3\x18j\"D\x3\xc6\xc4\x44\x18\xee\xa3=\xff\xd7\v$t]{\xce\x61\xad\xf3\x66\xcf\xddy\xdf\xe7<\xefs\xcey\xfb?\xef\xf9\x17\xa6\x30\x85\x31\x90j\x88\xb4\x84\xa2\xab\x5iu\xe2(\xba\xaf\xaf\xbb\xb3\xab\xd2Z5\x95\n\0 \xb2\x1a\xd8\xe0\x42\xb2\x80\x8a\r[\x95\n\0\b,\xf4\xc0i\xaa\x46\xadj\x18\x16\xbc\x99i\xa2\n-X\xb1\xe1P(~\r0\xcb\x3\xb5.\x10\x88\x36TZ\xcf\xd1p(\x14o\b\x6\xe3\xf5N\x9c\x9c\xcf\xb8\xb6\xc3\x45H\xad:r\x83\xc1x}(\x14w\xbc(G\xc3y1;\xd5gN\x6\xc3\xb1]\xcd\xe1\xd8\xb8\xc7\x1e\x88\xc4\x96Z\x86-\xde\xec\x82\xaal[\xd5\xda\xb6\xa2\x38\xde\x12\x89/h\tG\xdfR\x9f\x39i\x8b\xd9\xe1\xa4Q\xb6\xa7VE\xa2\xf7\x1a\x95\x43\x45\xe1\x8c\x31\xf2Z~\x9a}tZNv \xf2\x90W\xb3\x63\xabJ\x97\xed\xb3\x9f\xf0\xd9\xd2\xa8*[\x4\"\x85^,\xd1\x95=\xe9\xceO<\x1b\x8e\xc7\xe3\xbe\xc1\x11\xf3\x15\xca\x92\x32%\x87\x1\xff\x84\xccz\xd1\x10\xbe\x99\x35\xddZ\x96L&\xed\xe2T\xc9\x96\x18\x1c\x31\x8f\x38\x98\xa5l\xa1\xcb\x43y\re\xc9\xf9!\xddX*5\xee\xeG\"\x91\xba\x9c\xd6\x9e\0\x1c\x7fl\x93\x80\x33\x43~\xab\xb1?\x99\x1c,\f\x8e\xbb\xc3\x39\xad\xdd\xca\xffo\x16\xa0~\xe6\x88n-\xe\x8e\xb9\xc3\xe1p|^\x1e\x33@u\x1ey50\\\x83\xb5 \x93I\x9e\xba\x18\x18s\x87m\xb1\x1f\xa6\x12\xb3\xa2{\xc0\xba\xa7Vr\xf5\x62[W\x83\xb9\xf\xf8`\xc2z\xe0\xcf\x61\xd6\x15\x6\xc6\f?\xcbo^\xfc\xca\xe1#\xdf\x8e\xa2\xb2\x1d\xa8\xbd\f\xe1?Ty\xb4/\xd3\xd9[\x14?\x3\x1cj\x89\xb4}(\xaa\xed\xc0\x15\x97\xa1\x39\x82\xf0l_:\xb5\xb3\x30XrYk\tGo\x15\xe4}\xe0z/\xcajh\xe9\xebI\xedw\xe2\x4\xc3\xb1\x98\x42\x87G\xb3\xdfY\xc6\xac\xeb\xe9\xe9\xfa\xbe\x38QrY\xeb\xcbt~I\xde\xbf\x14x\xd7MY\xa0\xdd\xcd,@o&\x95\xc2K{(o\f\x9d\xab[^\xca,8\xcc\xc3\xd9\xec;g\x81\xf5\x81p\xb4\x1\xe4\xeer<\xa3V\xbb\xab\x89\x7f!b\xdaU\xad\x35\x65\xf3\xca\xc1\xde\xee\xd4\xd3N\x1an\xd3\x9a\"\xe2\x38\x89\xf9kr\xc7\\4.\x89\xe5\xc4\x91\xab\x16W\xbaiTe\x80\x9fL\xb8\x19\x16T\a\x9d\b\xc3\xf9\xda\xaa\x8d\x97\xa8\xfc\xe5\xa6Q\xb6\x87\xef\x8f\xc7gO\x1b\x36o*\x94\xed_\0K\xcc&\xe0s\xb7\x42\0\xaa\xd6&\x17\xc6\xca`8\xb6+?2\xf3\xa9\x3\a\xf6\x9e/\xc5(\xb9\xac\x5[c\xb7\x61xO\xe1:OF\xaa\xbd\xac\t'\x8c\xea\xda\xfd\x99\xce#\xc5)_\xe1\x41\"\x91\xb0\x66\xd4]\xf5\x1c\xca\xdb\xc0\x1cO\xe2\x80\b\xf\xdc\x30\x7f\xd1\xf?\x9d\x38\xf6\x63\xa9|K\xa4-\n\xb4\xe3\xfd-:G\x90\xc7\x1a\x9b\x16\xfe\xbd~\xed\x9a\xc3\xfd\xfd\xfdz\xe9Z\n\x10\b\xb7=\xf\xfa\xa2W\xa3\xc5P\xd8+X\xed\x62\x33`\xfbm\x91Qk\x11\xa2\x8f\v<8QM\x90m\xd9L\xc7K\x17\x8f\xc6\xf4\xb0m\xe5\xf6\xfaL\xcd\v\xc0\x8c\tI\xc3\x6\x30\x1b\xd4\aVN@\xd4\xfd$g\f\x91\x37{\n\x3\x63Z\xe2\xe7\x81\x81\xb3\x8d\v\x16NwzQL2\xb6g{:\xd3\x85\x81q\xcb\xda\x90\xdf\xf7*pz\xd2,\x95\xc7\xe9!\xbf\xf5Zq\xd0W\x1c\xf8\xe5\xe8\xd1\xd1\xf9\xf3o\xfc\x13\xc1q\xaf\xec\xbf\x86\xa2O\x1e\xecJ}Q\x1c/\xf9\xe2X\xbe\xec\xa6\xdd\xc0\xd7\xez\xc3U\xf0\xe4\xa4q\xe4\xf6[\x16\xef)\x95(i8\x91H\x18U\xd9\\\x14V\xe0#\xcb\x92;\xec\x1as-\x90\x9a\x98OP\xd8G\xde\x9a\x87\xe8\x9d\n\xfbJ\xd8\xda\x9cH$L\xa9s\x1d\xf7\xba\x2\xe1X\x17\x10\0v\xabX\xaf\xf7\xa5\x93\x3\x85\xf9U\xadm+T\xf5\x65U\xee\xf2\x62T\x84Om[\xb6\xed\xef\xe9\xf8\xec\x82\xef\vh\xe\xc7\x9a,\xf4\x19\x41\x36\"\xd2\x93MwD\xcbj8\x15\b\x85\xe2\r\x96\x35\x92O\xa7\xd3\xbf\x97\xbf\xa8\xb6\x66P\xd7y\xf8\x82\x61\xd3\xdc\x9b\xee\xfa\xb8\\>\x18\x8c\xd7[\x16\xbe\xee\xee\xe4o\x13\x32\xec\x5\xcd\xad\xads}\xa6\xe6\x94;\x13\xc8\xeb\xdcl\xb6\xf3\xd7J\xeaUc\a^\x2\xe1\xd8Y\xdcw0\xcf\x65\x33\xa9\xd9\x14\xb4\xc2\x44P\x8dyX\x81\xe3\x1ex\xc7\xa9\xd0,Ti\x80Wp\xff\xea\x10\xf5\xfc\x65\xe2\x84\xaa\xfc\xc7\x61!)\xd0Q'\x8e\x81\xb4S~\nS\x98 \xfe\x1\x1\xb5\x93\xa4\x97\x89\xb7\xcb\0\0\0\0IEND\xae\x42`\x82)" -iconText=Dock settings -iconVisibleInMenu=false -menuRole=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.MenuRole\x94\x93\x94\x8c\x11TextHeuristicRole\x94\x86\x94R\x94.) -priority=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0]\x80\x4\x95R\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\rPySide6.QtGui\x94\x8c\x10QAction.Priority\x94\x93\x94\x8c\xeNormalPriority\x94\x86\x94R\x94.) -shortcut= -shortcutContext=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0`\x80\x4\x95U\0\0\0\0\0\0\0\x8c\bbuiltins\x94\x8c\agetattr\x94\x93\x94\x8c\xePySide6.QtCore\x94\x8c\x12Qt.ShortcutContext\x94\x93\x94\x8c\xeWindowShortcut\x94\x86\x94R\x94.) -shortcutVisibleInContextMenu=false -statusTip= -text=Dock settings -toolTip=Dock settings -visible=true -whatsThis= - -[Perspectives] -1\Name=test -1\State="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" -size=1 - -[mainWindow] -DockingState="@ByteArray(\0\0\x1\xb3x\xdau\x90\x41O\xc3\x30\f\x85\xef\xfc\n+\xf7\xd1\xae\x12h\x87\x34\xd3V\xd8\x11\x98\xba\xb1sh\xcc\x14\xadMP\x92V\x3\xf1\xe3q\n\x8aV\x4\xa7\xd8\xcf/\xdfK\xcc\x97\xe7\xae\x85\x1\x9d\xd7\xd6\x94l~\x9d\x33@\xd3X\xa5\xcd\xb1\x64\xfb\xdd\x66\xb6`K\xc1\xb7\x61\xa5\x6i\x1aTw\xb6\x39\xd1\xac~\xf7\x1;xN\x17\x19\xec=\xba\xd4\x13\xa6\xb2&HmH\x89\x63\xc1S\xf\x9b\xd6\xca\x30\x6\xe4\xa4\xd7o\xad\xe\x81\xe4G\xa7\x91,a\x4|F@oB\xc9\n\xf2\xac\x1cJ\xd8\xc9\x97\x11\x5U\xef\x1c\xc6\xd1\x41\xe\xf8j]G\x8e\x83VG\f\xf0 ;\xbc\xd0\xa1j\xadG\x15\x83\x32\xc1\xb3\x88\x99\xc0\x8a\v\xd8\xfa\xbe\xda\xf6\xd8\xe3oX\xd2\xa7\xb0\x89\xe7\xc9z\x1d\xdf\x8dnm\xcf\x7f\xa7\xd6\xfa\x3\xbdX\xe4\x5\xcc\x8b\x9b[\xe0\xd9\xb7@\xe7\xcf\xff\xa9L+\xa2\xfa\x9f\x95\x8b\xab/_\xa2\x8f\x42)" -Geometry=@ByteArray(\x1\xd9\xd0\xcb\0\x3\0\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea\0\0\0\0\0\0\0\0\xff\xff\xff\xff\xff\xff\xff\xff\0\0\0\x1\0\0\0\0\xf\0\0\0\0\0\0\0\0\x1d\0\0\b\x1a\0\0\x3\xea) -State=@Variant(\0\0\0\x7f\0\0\0\x18PySide::PyObjectWrapper\0\0\0\0\xf\x80\x4\x95\x4\0\0\0\0\0\0\0\x43\0\x94.) - -[manifest] -widgets\1\closable=true -widgets\1\floatable=true -widgets\1\movable=true -widgets\1\object_name=BECQueue -widgets\1\widget_class=BECQueue -widgets\2\closable=true -widgets\2\floatable=true -widgets\2\movable=true -widgets\2\object_name=PositionerBox -widgets\2\widget_class=PositionerBox -widgets\3\closable=true -widgets\3\floatable=true -widgets\3\movable=true -widgets\3\object_name=Waveform -widgets\3\widget_class=Waveform -widgets\size=3 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 616dcc08c..bdfb9a5a2 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 @@ -1,17 +1,14 @@ from __future__ import annotations -from bec_qthemes import material_icon from qtpy.QtCore import Qt -from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget +from qtpy.QtGui import QFont +from qtpy.QtWidgets import QComboBox, QSizePolicy 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, -) +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_quick_profiles class ProfileComboBox(QComboBox): @@ -21,22 +18,47 @@ 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.""" + def refresh_profiles(self, active_profile: str | None = None): + """ + Refresh the profile list and ensure the active profile is visible. + + Args: + active_profile(str | None): The currently active profile name. + """ - current_text = self.currentText() + current_text = active_profile or 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) + quick_profiles = list_quick_profiles() + quick_set = set(quick_profiles) + + items = list(quick_profiles) + if active_profile and active_profile not in quick_set: + items.insert(0, active_profile) + + for profile in items: + self.addItem(profile) + idx = self.count() - 1 + + # Reset any custom styling + self.setItemData(idx, None, Qt.FontRole) + self.setItemData(idx, None, Qt.ToolTipRole) + self.setItemData(idx, None, Qt.ForegroundRole) + + if active_profile and profile == active_profile: + tooltip = "Active workspace profile" + if profile not in quick_set: + font = QFont(self.font()) + font.setItalic(True) + font.setBold(True) + self.setItemData(idx, font, Qt.FontRole) + self.setItemData(idx, self.palette().highlight().color(), Qt.ForegroundRole) + tooltip = "Active profile (not in quick select)" + self.setItemData(idx, tooltip, Qt.ToolTipRole) + self.setCurrentIndex(idx) + elif profile not in quick_set: + self.setItemData(idx, "Not in quick select", Qt.ToolTipRole) # Restore selection if possible index = self.findText(current_text) @@ -44,6 +66,14 @@ def refresh_profiles(self): self.setCurrentIndex(index) self.blockSignals(False) + if active_profile and self.currentText() != active_profile: + idx = self.findText(active_profile) + if idx >= 0: + self.setCurrentIndex(idx) + if active_profile and active_profile not in quick_set: + self.setToolTip("Active profile is not in quick select") + else: + self.setToolTip("") def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: @@ -56,17 +86,6 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: 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)) @@ -83,31 +102,31 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: ) # Delete workspace icon components.add_safe( - "refresh_workspace", + "reset_default_workspace", MaterialIconAction( - icon_name="refresh", + icon_name="undo", tooltip="Refresh Current Workspace", checkable=False, parent=components.toolbar, ), ) - # Delete workspace icon + # Workspace Manager icon components.add_safe( - "delete_workspace", + "manage_workspaces", MaterialIconAction( - icon_name="delete", - tooltip="Delete Current Workspace", - checkable=False, + icon_name="manage_accounts", + tooltip="Manage", + checkable=True, parent=components.toolbar, + label_text="Manage", ), ) 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") + bundle.add_action("reset_default_workspace") + bundle.add_action("manage_workspaces") return bundle @@ -128,56 +147,40 @@ def __init__(self, components: ToolbarComponents, target_widget=None): 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("reset_default_workspace").action.triggered.connect( + self._reset_workspace_to_default ) - self.components.get_action("delete_workspace").action.triggered.connect( - self.target_widget.delete_profile + self.components.get_action("manage_workspaces").action.triggered.connect( + self.target_widget.show_workspace_manager ) 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("reset_default_workspace").action.triggered.disconnect( + self._reset_workspace_to_default ) - self.components.get_action("delete_workspace").action.triggered.disconnect( - self.target_widget.delete_profile + self.components.get_action("manage_workspaces").action.triggered.disconnect( + self.target_widget.show_workspace_manager ) self._connected = False - @SafeSlot(bool) - def _lock_workspace(self, value: bool): - """ - Switches the workspace lock state and change the icon accordingly. - """ - setattr(self.target_widget, "lock_workspace", value) - self.components.get_action("lock").action.setChecked(value) - icon = material_icon( - "lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False - ) - self.components.get_action("lock").action.setIcon(icon) - @SafeSlot() - def _refresh_workspace(self): + def _reset_workspace_to_default(self): """ Refreshes the current workspace. """ - combo = self.components.get_action("workspace_combo").widget - current_workspace = combo.currentText() - self.target_widget.load_profile(current_workspace) + self.target_widget.restore_user_profile_from_default() diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index d6563a33e..6dfe76e59 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -1,28 +1,45 @@ # pylint: disable=missing-function-docstring, missing-module-docstring, unused-import +import base64 import os -import tempfile from unittest import mock from unittest.mock import MagicMock, patch import pytest -from qtpy.QtCore import QSettings +from qtpy.QtCore import QSettings, Qt +from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QDialog, QMessageBox +import bec_widgets.widgets.containers.advanced_dock_area.profile_utils as profile_utils from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( AdvancedDockArea, DockSettingsDialog, SaveProfileDialog, ) from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( - is_profile_readonly, + default_profile_path, + get_profile_info, + is_profile_read_only, + is_quick_select, list_profiles, - open_settings, - profile_path, + load_default_profile_screenshot, + load_user_profile_screenshot, + open_default_settings, + open_user_settings, + plugin_profiles_dir, read_manifest, - set_profile_readonly, + restore_user_from_default, + set_quick_select, + user_profile_path, write_manifest, ) +from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import ( + PreviewPanel, + RestoreProfileDialog, +) +from bec_widgets.widgets.containers.advanced_dock_area.settings.workspace_manager import ( + WorkSpaceManager, +) from .client_mocks import mocked_client @@ -36,12 +53,96 @@ def advanced_dock_area(qtbot, mocked_client): yield widget +@pytest.fixture(autouse=True) +def isolate_profile_storage(tmp_path, monkeypatch): + """Ensure each test writes profiles into a unique temporary directory.""" + root = tmp_path / "profiles_root" + root.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(root)) + yield + + @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 + """Return the current temporary profile directory.""" + return os.environ["BECWIDGETS_PROFILE_DIR"] + + +@pytest.fixture +def module_profile_factory(monkeypatch, tmp_path): + """Provide a helper to create synthetic module-level (read-only) profiles.""" + module_dir = tmp_path / "module_profiles" + module_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(profile_utils, "module_profiles_dir", lambda: str(module_dir)) + monkeypatch.setattr(profile_utils, "plugin_profiles_dir", lambda: None) + + def _create(name="readonly_profile", content="[profile]\n"): + path = module_dir / f"{name}.ini" + path.write_text(content) + return name + + return _create + + +@pytest.fixture +def workspace_manager_target(): + class _Signal: + def __init__(self): + self._slot = None + + def connect(self, slot): + self._slot = slot + + def emit(self, value): + if self._slot: + self._slot(value) + + class _Combo: + def __init__(self): + self.current_text = "" + + def setCurrentText(self, text): + self.current_text = text + + class _Action: + def __init__(self, widget): + self.widget = widget + + class _Components: + def __init__(self, combo): + self._combo = combo + + def get_action(self, name): + return _Action(self._combo) + + class _Toolbar: + def __init__(self, combo): + self.components = _Components(combo) + + class _Target: + def __init__(self): + self.profile_changed = _Signal() + self._combo = _Combo() + self.toolbar = _Toolbar(self._combo) + self._current_profile_name = None + self.load_profile_calls = [] + self.save_called = False + self.refresh_calls = 0 + + def load_profile(self, name): + self.load_profile_calls.append(name) + self._current_profile_name = name + + def save_profile(self): + self.save_called = True + + def _refresh_workspace_list(self): + self.refresh_calls += 1 + + def _factory(): + return _Target() + + return _factory class TestAdvancedDockAreaInit: @@ -81,7 +182,7 @@ def test_new_widget_string(self, advanced_dock_area, qtbot): initial_count = len(advanced_dock_area.dock_list()) # Create a widget by string name - widget = advanced_dock_area.new("Waveform") + widget = advanced_dock_area.new("DarkModeButton") # Wait for the dock to be created (since it's async) qtbot.wait(200) @@ -430,7 +531,6 @@ def test_save_profile_dialog_init(self, qtbot): 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.""" @@ -438,10 +538,10 @@ def test_save_profile_dialog_get_values(self, qtbot): qtbot.addWidget(dialog) dialog.name_edit.setText("my_profile") - dialog.readonly_checkbox.setChecked(True) + dialog.quick_select_checkbox.setChecked(True) assert dialog.get_profile_name() == "my_profile" - assert dialog.is_readonly() is True + assert dialog.is_quick_select() is True def test_save_button_enabled_state(self, qtbot): """Test save button is enabled/disabled based on name input.""" @@ -459,56 +559,568 @@ def test_save_button_enabled_state(self, qtbot): dialog.name_edit.setText("") assert not dialog.save_btn.isEnabled() + def test_accept_blocks_empty_name(self, qtbot): + dialog = SaveProfileDialog(None) + qtbot.addWidget(dialog) + dialog.name_edit.clear() + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.overwrite_existing is False + + def test_accept_readonly_suggests_unique_name(self, qtbot, monkeypatch): + info_calls = [] + monkeypatch.setattr( + QMessageBox, + "information", + lambda *args, **kwargs: info_calls.append((args, kwargs)) or QMessageBox.Ok, + ) + + dialog = SaveProfileDialog( + None, + name_exists=lambda name: name == "readonly_custom", + profile_origin=lambda name: "module" if name == "readonly" else "unknown", + origin_label=lambda name: "ModuleDefaults", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("readonly") + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.name_edit.text().startswith("readonly_custom") + assert dialog.overwrite_checkbox.isChecked() is False + assert info_calls, "Expected informational prompt for read-only profile" + + def test_accept_existing_profile_confirm_yes(self, qtbot, monkeypatch): + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes) + + dialog = SaveProfileDialog( + None, + current_profile_name="profile_a", + name_exists=lambda name: name == "profile_a", + profile_origin=lambda name: "settings" if name == "profile_a" else "unknown", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("profile_a") + + dialog.accept() + + assert dialog.result() == QDialog.Accepted + assert dialog.overwrite_existing is True + + def test_accept_existing_profile_confirm_no(self, qtbot, monkeypatch): + monkeypatch.setattr(QMessageBox, "question", lambda *args, **kwargs: QMessageBox.No) + + dialog = SaveProfileDialog( + None, + current_profile_name="profile_a", + name_exists=lambda name: False, + profile_origin=lambda name: "settings" if name == "profile_a" else "unknown", + ) + qtbot.addWidget(dialog) + dialog.name_edit.setText("profile_a") + + dialog.accept() + + assert dialog.result() == QDialog.Rejected + assert dialog.name_edit.text().startswith("profile_a_custom") + assert dialog.overwrite_existing is False + assert dialog.overwrite_checkbox.isChecked() is False + + def test_overwrite_toggle_sets_and_restores_name(self, qtbot): + dialog = SaveProfileDialog( + None, current_name="custom_name", current_profile_name="existing_profile" + ) + qtbot.addWidget(dialog) + + dialog.overwrite_checkbox.setChecked(True) + assert dialog.name_edit.text() == "existing_profile" + dialog.name_edit.setText("existing_profile") + dialog.overwrite_checkbox.setChecked(False) + assert dialog.name_edit.text() == "custom_name" + + +class TestPreviewPanel: + """Test preview panel scaling behavior.""" + + def test_preview_panel_without_pixmap(self, qtbot): + panel = PreviewPanel("Current", None) + qtbot.addWidget(panel) + assert "No preview available" in panel.image_label.text() + + def test_preview_panel_with_pixmap(self, qtbot): + pixmap = QPixmap(40, 20) + pixmap.fill(Qt.red) + panel = PreviewPanel("Current", pixmap) + qtbot.addWidget(panel) + assert panel.image_label.pixmap() is not None + + def test_preview_panel_set_pixmap_resets_placeholder(self, qtbot): + panel = PreviewPanel("Current", None) + qtbot.addWidget(panel) + pixmap = QPixmap(30, 30) + pixmap.fill(Qt.blue) + panel.setPixmap(pixmap) + assert panel.image_label.pixmap() is not None + panel.setPixmap(None) + assert panel.image_label.pixmap() is None or panel.image_label.pixmap().isNull() + assert "No preview available" in panel.image_label.text() + + +class TestRestoreProfileDialog: + """Test restore dialog confirmation flow.""" + + def test_confirm_accepts(self, monkeypatch): + monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Accepted) + assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is True + + def test_confirm_rejects(self, monkeypatch): + monkeypatch.setattr(RestoreProfileDialog, "exec", lambda self: QDialog.Rejected) + assert RestoreProfileDialog.confirm(None, QPixmap(), QPixmap()) is False + + +class TestProfileInfoAndScreenshots: + """Tests for profile utilities metadata and screenshot helpers.""" + + PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAAFUlEQVQYlWP8//8/A27AhEduBEsDAKXjAxHmByO3AAAAAElFTkSuQmCC" + ) + + def _write_manifest(self, settings, count=2): + settings.beginWriteArray(profile_utils.SETTINGS_KEYS["manifest"], count) + for i in range(count): + settings.setArrayIndex(i) + settings.setValue("object_name", f"widget_{i}") + settings.setValue("widget_class", "Dummy") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + def test_get_profile_info_user_origin(self, temp_profile_dir): + name = "info_user" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["created_at"], "2023-01-01T00:00:00Z") + settings.setValue("profile/author", "Custom") + set_quick_select(name, True) + self._write_manifest(settings, count=3) + + info = get_profile_info(name) + + assert info.name == name + assert info.origin == "settings" + assert info.is_read_only is False + assert info.is_quick_select is True + assert info.widget_count == 3 + assert info.author == "User" + assert info.user_path.endswith(f"{name}.ini") + assert info.size_kb >= 0 + + def test_get_profile_info_default_only(self, temp_profile_dir): + name = "info_default" + settings = open_default_settings(name) + self._write_manifest(settings, count=1) + + user_path = user_profile_path(name) + if os.path.exists(user_path): + os.remove(user_path) + + info = get_profile_info(name) + + assert info.origin == "settings" + assert info.user_path.endswith(f"{name}.ini") + assert info.widget_count == 1 + + def test_get_profile_info_module_readonly(self, module_profile_factory): + name = module_profile_factory("info_readonly") + info = get_profile_info(name) + assert info.origin == "module" + assert info.is_read_only is True + assert info.author == "BEC Widgets" + + def test_get_profile_info_unknown_profile(self): + name = "nonexistent_profile" + if os.path.exists(user_profile_path(name)): + os.remove(user_profile_path(name)) + if os.path.exists(default_profile_path(name)): + os.remove(default_profile_path(name)) + + info = get_profile_info(name) + + assert info.origin == "unknown" + assert info.is_read_only is False + assert info.widget_count == 0 + + def test_load_user_profile_screenshot(self, temp_profile_dir): + name = "user_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = load_user_profile_screenshot(name) + + assert pix is not None and not pix.isNull() + + def test_load_default_profile_screenshot(self, temp_profile_dir): + name = "default_screenshot" + settings = open_default_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = load_default_profile_screenshot(name) + + assert pix is not None and not pix.isNull() + + def test_load_screenshot_from_settings_invalid(self, temp_profile_dir): + name = "invalid_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], "not-an-image") + settings.sync() + + pix = profile_utils._load_screenshot_from_settings(settings) + + assert pix is None + + def test_load_screenshot_from_settings_bytes(self, temp_profile_dir): + name = "bytes_screenshot" + settings = open_user_settings(name) + settings.setValue(profile_utils.SETTINGS_KEYS["screenshot"], self.PNG_BYTES) + settings.sync() + + pix = profile_utils._load_screenshot_from_settings(settings) + + assert pix is not None and not pix.isNull() + + +class TestWorkSpaceManager: + """Test workspace manager interactions.""" + + @staticmethod + def _create_profiles(names): + for name in names: + settings = open_user_settings(name) + settings.setValue("meta", "value") + settings.sync() + + def test_render_table_populates_rows(self, qtbot): + profile_names = ["profile_a", "profile_b"] + self._create_profiles(profile_names) + + manager = WorkSpaceManager(target_widget=None) + qtbot.addWidget(manager) + + assert manager.profile_table.rowCount() >= len(profile_names) + + def test_switch_profile_updates_target(self, qtbot, workspace_manager_target): + name = "profile_switch" + self._create_profiles([name]) + target = workspace_manager_target() + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + manager.switch_profile(name) + + assert target.load_profile_calls == [name] + assert target._combo.current_text == name + assert manager._current_selected_profile() == name + + def test_toggle_quick_select_updates_flag(self, qtbot, workspace_manager_target): + name = "profile_toggle" + self._create_profiles([name]) + target = workspace_manager_target() + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + initial = is_quick_select(name) + manager.toggle_quick_select(name) + + assert is_quick_select(name) is (not initial) + assert target.refresh_calls >= 1 + + def test_save_current_as_profile_with_target(self, qtbot, workspace_manager_target): + name = "profile_save" + self._create_profiles([name]) + target = workspace_manager_target() + target._current_profile_name = name + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + manager.save_current_as_profile() + + assert target.save_called is True + assert manager._current_selected_profile() == name + + def test_delete_profile_removes_files(self, qtbot, workspace_manager_target, monkeypatch): + name = "profile_delete" + self._create_profiles([name]) + target = workspace_manager_target() + target._current_profile_name = name + manager = WorkSpaceManager(target_widget=target) + qtbot.addWidget(manager) + + monkeypatch.setattr(QMessageBox, "question", lambda *a, **k: QMessageBox.Yes) + + manager.delete_profile(name) + + assert not os.path.exists(user_profile_path(name)) + assert target.refresh_calls >= 1 + + def test_delete_readonly_profile_shows_message( + self, qtbot, workspace_manager_target, module_profile_factory, monkeypatch + ): + readonly = module_profile_factory("readonly_delete") + list_profiles() + info_calls = [] + monkeypatch.setattr( + QMessageBox, + "information", + lambda *args, **kwargs: info_calls.append((args, kwargs)) or QMessageBox.Ok, + ) + manager = WorkSpaceManager(target_widget=workspace_manager_target()) + qtbot.addWidget(manager) + + manager.delete_profile(readonly) + + assert info_calls, "Expected informational prompt for read-only profile" + + +class TestAdvancedDockAreaRestoreAndDialogs: + """Additional coverage for restore flows and workspace dialogs.""" + + def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area, monkeypatch): + profile_name = "profile_restore_true" + open_default_settings(profile_name).sync() + open_user_settings(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + advanced_dock_area.isVisible = lambda: False + pix = QPixmap(8, 8) + pix.fill(Qt.red) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", + lambda name: pix, + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", + lambda name: pix, + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", + lambda *args, **kwargs: True, + ) + + with ( + patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default" + ) as mock_restore, + patch.object(advanced_dock_area, "delete_all") as mock_delete_all, + patch.object(advanced_dock_area, "load_profile") as mock_load_profile, + ): + advanced_dock_area.restore_user_profile_from_default() + + mock_restore.assert_called_once_with(profile_name) + mock_delete_all.assert_called_once() + mock_load_profile.assert_called_once_with(profile_name) + + def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_area, monkeypatch): + profile_name = "profile_restore_false" + open_default_settings(profile_name).sync() + open_user_settings(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + advanced_dock_area.isVisible = lambda: False + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", + lambda name: QPixmap(), + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", + lambda name: QPixmap(), + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", + lambda *args, **kwargs: False, + ) + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.restore_user_from_default" + ) as mock_restore: + advanced_dock_area.restore_user_profile_from_default() + + mock_restore.assert_not_called() + + def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, monkeypatch): + advanced_dock_area._current_profile_name = None + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm" + ) as mock_confirm: + advanced_dock_area.restore_user_profile_from_default() + mock_confirm.assert_not_called() + + def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area): + profile_name = "refresh_profile" + open_user_settings(profile_name).sync() + advanced_dock_area._current_profile_name = profile_name + combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles = MagicMock() + + advanced_dock_area._refresh_workspace_list() + + combo.refresh_profiles.assert_called_once_with(profile_name) + + def test_refresh_workspace_list_fallback(self, advanced_dock_area): + class ComboStub: + def __init__(self): + self.items = [] + self.tooltip = "" + self.block_calls = [] + self.cleared = False + self.current_index = -1 + + def blockSignals(self, value): + self.block_calls.append(value) + + def clear(self): + self.items.clear() + self.cleared = True + + def addItems(self, items): + self.items.extend(items) + + def findText(self, text): + try: + return self.items.index(text) + except ValueError: + return -1 + + def setCurrentIndex(self, idx): + self.current_index = idx + + def setToolTip(self, text): + self.tooltip = text + + active = "active_profile" + quick = "quick_profile" + open_user_settings(active).sync() + open_user_settings(quick).sync() + set_quick_select(quick, True) + + combo_stub = ComboStub() + + class StubAction: + def __init__(self, widget): + self.widget = widget + + with patch.object( + advanced_dock_area.toolbar.components, "get_action", return_value=StubAction(combo_stub) + ): + advanced_dock_area._current_profile_name = active + advanced_dock_area._refresh_workspace_list() + + assert combo_stub.block_calls == [True, False] + assert combo_stub.items[0] == active + assert combo_stub.tooltip == "Active profile is not in quick select" + + def test_show_workspace_manager_creates_dialog(self, qtbot, advanced_dock_area): + action = advanced_dock_area.toolbar.components.get_action("manage_workspaces").action + assert not action.isChecked() + + advanced_dock_area._current_profile_name = "manager_profile" + open_user_settings("manager_profile").sync() + + advanced_dock_area.show_workspace_manager() + + assert advanced_dock_area.manage_dialog is not None + assert advanced_dock_area.manage_dialog.isVisible() + assert action.isChecked() + assert isinstance(advanced_dock_area.manage_widget, WorkSpaceManager) + + advanced_dock_area.manage_dialog.close() + qtbot.waitUntil(lambda: advanced_dock_area.manage_dialog is None) + assert not action.isChecked() + + def test_manage_dialog_closed(self, advanced_dock_area): + widget_mock = MagicMock() + dialog_mock = MagicMock() + advanced_dock_area.manage_widget = widget_mock + advanced_dock_area.manage_dialog = dialog_mock + action = advanced_dock_area.toolbar.components.get_action("manage_workspaces").action + action.setChecked(True) + + advanced_dock_area._manage_dialog_closed() + + widget_mock.close.assert_called_once() + widget_mock.deleteLater.assert_called_once() + dialog_mock.deleteLater.assert_called_once() + assert advanced_dock_area.manage_dialog is None + assert not action.isChecked() + class TestProfileManagement: """Test profile management functionality.""" 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") + path = user_profile_path("test_profile") + expected = os.path.join(temp_profile_dir, "user", "test_profile.ini") assert path == expected + default_path = default_profile_path("test_profile") + expected_default = os.path.join(temp_profile_dir, "default", "test_profile.ini") + assert default_path == expected_default + def test_open_settings(self, temp_profile_dir): """Test opening settings for a profile.""" - settings = open_settings("test_profile") + settings = open_user_settings("test_profile") assert isinstance(settings, QSettings) def test_list_profiles_empty(self, temp_profile_dir): """Test listing profiles when directory is empty.""" + try: + module_defaults = { + os.path.splitext(f)[0] + for f in os.listdir(profile_utils.module_profiles_dir()) + if f.endswith(".ini") + } + except FileNotFoundError: + module_defaults = set() profiles = list_profiles() - assert profiles == [] + assert module_defaults.issubset(set(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 = open_user_settings(name) settings.setValue("test", "value") settings.sync() profiles = list_profiles() - assert sorted(profiles) == sorted(profile_names) + for name in profile_names: + assert name in profiles - def test_readonly_profile_operations(self, temp_profile_dir): + def test_readonly_profile_operations(self, temp_profile_dir, module_profile_factory): """Test read-only profile functionality.""" - profile_name = "readonly_profile" + profile_name = "user_profile" # Initially should not be read-only - assert not is_profile_readonly(profile_name) + assert not is_profile_read_only(profile_name) - # Set as read-only - set_profile_readonly(profile_name, True) - assert is_profile_readonly(profile_name) + # Create a user profile and ensure it's writable + settings = open_user_settings(profile_name) + settings.setValue("test", "value") + settings.sync() + assert not is_profile_read_only(profile_name) - # Unset read-only - set_profile_readonly(profile_name, False) - assert not is_profile_readonly(profile_name) + # Verify a bundled module profile is detected as read-only + readonly_name = module_profile_factory("module_default") + assert is_profile_read_only(readonly_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") + settings = open_user_settings("test_manifest") # Create real docks advanced_dock_area.new("DarkModeButton") @@ -535,44 +1147,65 @@ def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtb assert "floatable" in item assert "movable" in item + def test_restore_preserves_quick_select(self, temp_profile_dir): + """Ensure restoring keeps the quick select flag when it was enabled.""" + profile_name = "restorable_profile" + default_settings = open_default_settings(profile_name) + default_settings.setValue("test", "default") + default_settings.sync() + + user_settings = open_user_settings(profile_name) + user_settings.setValue("test", "user") + user_settings.sync() + + set_quick_select(profile_name, True) + assert is_quick_select(profile_name) + + restore_user_from_default(profile_name) + + assert is_quick_select(profile_name) + class TestWorkspaceProfileOperations: """Test workspace profile save/load/delete operations.""" - def test_save_profile_readonly_conflict(self, advanced_dock_area, temp_profile_dir): + def test_save_profile_readonly_conflict( + self, advanced_dock_area, temp_profile_dir, module_profile_factory + ): """Test saving profile when read-only profile exists.""" - profile_name = "readonly_profile" + profile_name = module_profile_factory("readonly_profile") + new_profile = f"{profile_name}_custom" + target_path = user_profile_path(new_profile) + if os.path.exists(target_path): + os.remove(target_path) - # Create a read-only profile - set_profile_readonly(profile_name, True) - settings = open_settings(profile_name) - settings.setValue("test", "value") - settings.sync() + class StubDialog: + def __init__(self, *args, **kwargs): + self.overwrite_existing = False - 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 + def exec(self): + return QDialog.Accepted - with patch( - "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning" - ) as mock_warning: - mock_warning.return_value = QMessageBox.No + def get_profile_name(self): + return new_profile - advanced_dock_area.save_profile() + def is_quick_select(self): + return False + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", + StubDialog, + ): + advanced_dock_area.save_profile(profile_name) - mock_warning.assert_called_once() + assert os.path.exists(target_path) 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 = open_user_settings(profile_name) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "test_widget") @@ -583,8 +1216,6 @@ def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, settings.endArray() settings.sync() - initial_count = len(advanced_dock_area.widget_map()) - # Load profile advanced_dock_area.load_profile(profile_name) @@ -595,15 +1226,96 @@ def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, 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" + def test_save_as_skips_autosave_source_profile( + self, advanced_dock_area, temp_profile_dir, qtbot + ): + """Saving a new profile avoids overwriting the source profile during the switch.""" + source_profile = "autosave_source" + new_profile = "autosave_new" + + settings = open_user_settings(source_profile) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", "source_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + advanced_dock_area.load_profile(source_profile) + qtbot.wait(500) + advanced_dock_area.new("DarkModeButton") + qtbot.wait(500) + + class StubDialog: + def __init__(self, *args, **kwargs): + self.overwrite_existing = False + + def exec(self): + return QDialog.Accepted + + def get_profile_name(self): + return new_profile + + def is_quick_select(self): + return False + + with patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", + StubDialog, + ): + advanced_dock_area.save_profile() + + qtbot.wait(500) + source_manifest = read_manifest(open_user_settings(source_profile)) + new_manifest = read_manifest(open_user_settings(new_profile)) + + assert len(source_manifest) == 1 + assert len(new_manifest) == 2 + + def test_switch_autosaves_previous_profile(self, advanced_dock_area, temp_profile_dir, qtbot): + """Regular profile switches should persist the outgoing layout.""" + profile_a = "autosave_keep" + profile_b = "autosave_target" + + for profile in (profile_a, profile_b): + settings = open_user_settings(profile) + settings.beginWriteArray("manifest/widgets", 1) + settings.setArrayIndex(0) + settings.setValue("object_name", f"{profile}_widget") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.endArray() + settings.sync() + + advanced_dock_area.load_profile(profile_a) + qtbot.wait(500) + advanced_dock_area.new("DarkModeButton") + qtbot.wait(500) + + advanced_dock_area.load_profile(profile_b) + qtbot.wait(500) + + manifest_a = read_manifest(open_user_settings(profile_a)) + assert len(manifest_a) == 2 - # Create read-only profile - set_profile_readonly(profile_name, True) - settings = open_settings(profile_name) + def test_delete_profile_readonly( + self, advanced_dock_area, temp_profile_dir, module_profile_factory + ): + """Test deleting bundled profile removes only the writable copy.""" + profile_name = module_profile_factory("readonly_profile") + list_profiles() # ensure default and user copies are materialized + settings = open_user_settings(profile_name) settings.setValue("test", "value") settings.sync() + user_path = user_profile_path(profile_name) + default_path = default_profile_path(profile_name) + assert os.path.exists(user_path) + assert os.path.exists(default_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: mock_combo = MagicMock() @@ -611,22 +1323,27 @@ def test_delete_profile_readonly(self, advanced_dock_area, temp_profile_dir): 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: + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question" + ) as mock_question: + mock_question.return_value = QMessageBox.Yes + advanced_dock_area.delete_profile() - mock_warning.assert_called_once() - # Profile should still exist - assert os.path.exists(profile_path(profile_name)) + mock_question.assert_called_once() + # User copy should be removed, default remains + assert not os.path.exists(user_path) + assert os.path.exists(default_path) 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 = open_user_settings(profile_name) settings.setValue("test", "value") settings.sync() + user_path = user_profile_path(profile_name) + assert os.path.exists(user_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: mock_combo = MagicMock() @@ -644,13 +1361,13 @@ 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(user_path) 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 = open_user_settings(name) settings.setValue("test", "value") settings.sync() From 003fff7e4b5cdff0ea7d44d43ccfcea4a5d7e345 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 27 Oct 2025 19:25:10 +0100 Subject: [PATCH 127/161] fix(advanced_dock_area): disable developer mode switch --- .../advanced_dock_area/advanced_dock_area.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 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 e72224d14..489d24512 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,9 +171,9 @@ def __init__( # 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) + # Sync Developer toggle icon state after initial setup #TODO temporary disable + # dev_action = self.toolbar.components.get_action("developer_mode").action + # dev_action.setChecked(self._editable) # Apply the requested mode after everything is set up self.mode = mode @@ -391,18 +391,18 @@ def _build_flat_bundles(category: 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 - ), - ) + # Developer mode toggle (moved from menu into toolbar) #TODO temporary disable + # 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") + # bda.add_action("developer_mode") #TODO temporary disable self.toolbar.add_bundle(bda) # Default bundle configuration (show menus by default) @@ -468,10 +468,10 @@ def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str] 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 - ) + # Developer mode toggle #TODO temporary disable + # 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 @@ -495,8 +495,8 @@ def _set_editable(self, editable: bool) -> None: else: self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) - # Keep Developer mode UI in sync - self.toolbar.components.get_action("developer_mode").action.setChecked(editable) + # Keep Developer mode UI in sync #TODO temporary disable + # self.toolbar.components.get_action("developer_mode").action.setChecked(editable) def _on_developer_mode_toggled(self, checked: bool) -> None: """Handle developer mode checkbox toggle.""" From 28c0b3b18bed77f22d0ad9e25c5acbce9e72df25 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 7 Nov 2025 14:05:59 +0100 Subject: [PATCH 128/161] fix(main_window): removed general forced cleanup --- .../widgets/containers/main_window/main_window.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 16f8dfe37..27b43dfcf 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -458,21 +458,6 @@ def event(self, event): return super().event(event) def cleanup(self): - central_widget = self.centralWidget() - if central_widget is not None: - central_widget.close() - central_widget.deleteLater() - if not isinstance(central_widget, BECWidget): - # if the central widget is not a BECWidget, we need to call the cleanup method - # of all widgets whose parent is the current BECMainWindow - children = self.findChildren(BECWidget) - for child in children: - ancestor = WidgetHierarchy._get_becwidget_ancestor(child) - if ancestor is self: - if shiboken6.isValid(child): - child.close() - child.deleteLater() - # Timer cleanup if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive(): self._client_info_expire_timer.stop() From d259de227d9a9e4328761f2bf75118bd375fdb0d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 4 Nov 2025 16:59:23 +0100 Subject: [PATCH 129/161] feat(advanced_dock_area): created DockAreaWidget base class; profile management through namespaces; dock area variants --- .../advanced_dock_area/advanced_dock_area.py | 687 ++++----- .../advanced_dock_area/basic_dock_area.py | 1368 +++++++++++++++++ .../advanced_dock_area/profile_utils.py | 679 ++++++-- .../advanced_dock_area/settings/dialogs.py | 21 +- .../settings/workspace_manager.py | 34 +- .../toolbar_components/workspace_actions.py | 66 +- .../containers/main_window/main_window.py | 2 - tests/unit_tests/test_advanced_dock_area.py | 522 ++++++- 8 files changed, 2761 insertions(+), 618 deletions(-) create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.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 489d24512..a7a062ba1 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 Literal, cast +from typing import Callable, Literal, Mapping, Sequence import PySide6QtAds as QtAds from bec_lib import bec_logger -from PySide6QtAds import CDockManager, CDockWidget +from PySide6QtAds import CDockWidget from qtpy.QtCore import QTimer, Signal from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( @@ -17,13 +17,11 @@ 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.colors import apply_theme -from bec_widgets.utils.property_editor import PropertyEditor from bec_widgets.utils.toolbars.actions import ( ExpandableMenuAction, MaterialIconAction, @@ -32,11 +30,15 @@ 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.basic_dock_area import DockAreaWidget from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( SETTINGS_KEYS, - default_profile_path, + default_profile_candidates, + delete_profile_files, get_last_profile, + is_profile_read_only, is_quick_select, + list_quick_profiles, load_default_profile_screenshot, load_user_profile_screenshot, now_iso_utc, @@ -46,9 +48,10 @@ profile_origin_display, read_manifest, restore_user_from_default, + sanitize_namespace, set_last_profile, set_quick_select, - user_profile_path, + user_profile_candidates, write_manifest, ) from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import ( @@ -80,30 +83,26 @@ logger = bec_logger.logger +_PROFILE_NAMESPACE_UNSET = object() -class DockSettingsDialog(QDialog): +PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")} - 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 AdvancedDockArea(BECWidget, QWidget): +class AdvancedDockArea(DockAreaWidget): RPC = True PLUGIN = False USER_ACCESS = [ "new", + "dock_map", + "dock_list", "widget_map", "widget_list", "lock_workspace", "attach_all", "delete_all", + "set_layout_ratios", + "describe_layout", + "print_layout_structure", "mode", "mode.setter", ] @@ -115,38 +114,34 @@ class AdvancedDockArea(BECWidget, QWidget): def __init__( self, parent=None, - mode: str = "developer", + mode: Literal["plot", "device", "utils", "user", "creator"] = "creator", default_add_direction: Literal["left", "right", "top", "bottom"] = "right", - *args, + profile_namespace: str | None = None, + auto_profile_namespace: bool = True, + auto_save_upon_exit: bool = True, + enable_profile_management: bool = True, + restore_initial_profile: bool = True, **kwargs, ): - super().__init__(parent=parent, *args, **kwargs) - - # Title (as a top-level QWidget it can have a window title) - self.setWindowTitle("Advanced Dock Area") - - # Top-level layout hosting a toolbar and the dock manager - self._root_layout = QVBoxLayout(self) - self._root_layout.setContentsMargins(0, 0, 0, 0) - self._root_layout.setSpacing(0) - - # Init Dock Manager - self.dock_manager = CDockManager(self) - self.dock_manager.setStyleSheet("") - - # Dock manager helper variables - self._locked = False # Lock state of the workspace + self._profile_namespace_hint = profile_namespace + self._profile_namespace_auto = auto_profile_namespace + self._profile_namespace_resolved: str | None | object = _PROFILE_NAMESPACE_UNSET + self._auto_save_upon_exit = auto_save_upon_exit + self._profile_management_enabled = enable_profile_management + self._restore_initial_profile = restore_initial_profile + super().__init__( + parent, + default_add_direction=default_add_direction, + title="Advanced Dock Area", + **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" - ) + self._mode = mode # Toolbar self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) + self.dark_mode_button.setVisible(enable_profile_management) self._setup_toolbar() self._hook_toolbar() @@ -154,14 +149,14 @@ def __init__( self.save_dialog = None self.manage_dialog = None - # Place toolbar and dock manager into layout - self._root_layout.addWidget(self.toolbar) - self._root_layout.addWidget(self.dock_manager, 1) + # Place toolbar above the dock manager provided by the base class + self._root_layout.insertWidget(0, self.toolbar) # Populate and hook the workspace combo self._refresh_workspace_list() self._current_profile_name = None self._pending_autosave_skip: tuple[str, str] | None = None + self._exit_snapshot_written = False # State manager self.state_manager = WidgetStateManager(self) @@ -177,79 +172,96 @@ def __init__( # Apply the requested mode after everything is set up self.mode = mode - QTimer.singleShot( - 0, self._fetch_initial_profile - ) # To allow full init before loading profile and prevent segfault on exit + if self._restore_initial_profile: + self._fetch_initial_profile() def _fetch_initial_profile(self): # Restore last-used profile if available; otherwise fall back to combo selection combo = self.toolbar.components.get_action("workspace_combo").widget - last = get_last_profile() - if last and ( - os.path.exists(user_profile_path(last)) or os.path.exists(default_profile_path(last)) - ): - init_profile = last + namespace = self.profile_namespace + last = get_last_profile(namespace) + if last: + user_exists = any( + os.path.exists(path) for path in user_profile_candidates(last, namespace) + ) + default_exists = any( + os.path.exists(path) for path in default_profile_candidates(last, namespace) + ) + init_profile = last if (user_exists or default_exists) else None else: init_profile = combo.currentText() + if not init_profile: + general_exists = any( + os.path.exists(path) for path in user_profile_candidates("general", namespace) + ) or any( + os.path.exists(path) for path in default_profile_candidates("general", namespace) + ) + if general_exists: + init_profile = "general" if init_profile: - self.load_profile(init_profile) - combo.setCurrentText(init_profile) + # Defer initial load to the event loop so child widgets exist before state restore. + QTimer.singleShot(0, lambda: self._load_initial_profile(init_profile)) - def _make_dock( + def _load_initial_profile(self, name: str) -> None: + """Load the initial profile after construction when the event loop is running.""" + self.load_profile(name) + combo = self.toolbar.components.get_action("workspace_combo").widget + combo.blockSignals(True) + combo.setCurrentText(name) + combo.blockSignals(False) + + def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None: + prefs = getattr(dock, "_dock_preferences", {}) or {} + if prefs.get("show_settings_action") is None: + prefs = dict(prefs) + prefs["show_settings_action"] = True + dock._dock_preferences = prefs + super()._customize_dock(dock, widget) + + @SafeSlot(popup_error=True) + def new( self, - widget: QWidget, + widget: QWidget | str, *, - closable: bool, - floatable: bool, + closable: bool = True, + floatable: bool = True, 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() + where: Literal["left", "right", "top", "bottom"] | None = None, + on_close: Callable[[CDockWidget, QWidget], None] | None = None, + tab_with: CDockWidget | QWidget | str | None = None, + relative_to: CDockWidget | QWidget | str | None = None, + return_dock: bool = False, + show_title_bar: bool | None = None, + title_buttons: Mapping[str, bool] | Sequence[str] | str | None = None, + show_settings_action: bool | None = None, + promote_central: bool = False, + **widget_kwargs, + ) -> QWidget | CDockWidget | BECWidget: + """ + Override the base helper so dock settings are available by default. + + The flag remains user-configurable (pass ``False`` to hide the action). + """ + if show_settings_action is None: + show_settings_action = True + return super().new( + widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + where=where, + on_close=on_close, + tab_with=tab_with, + relative_to=relative_to, + return_dock=return_dock, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + **widget_kwargs, + ) def _apply_dock_lock(self, locked: bool) -> None: if locked: @@ -257,28 +269,6 @@ def _apply_dock_lock(self, locked: bool) -> None: else: self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures) - def _delete_dock(self, dock: CDockWidget) -> None: - w = dock.widget() - if w and isValid(w): - w.close() - w.deleteLater() - if isValid(dock): - dock.closeDockWidget() - dock.deleteDockWidget() - - def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea: - """Return ADS DockWidgetArea from a human-friendly direction string. - If *where* is None, fall back to instance default. - """ - d = (where or getattr(self, "_default_add_direction", "right") or "right").lower() - mapping = { - "left": QtAds.DockWidgetArea.LeftDockWidgetArea, - "right": QtAds.DockWidgetArea.RightDockWidgetArea, - "top": QtAds.DockWidgetArea.TopDockWidgetArea, - "bottom": QtAds.DockWidgetArea.BottomDockWidgetArea, - } - return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea) - ################################################################################ # Toolbar Setup ################################################################################ @@ -353,7 +343,12 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) self.toolbar.components.add_safe( flat_action_id, MaterialIconAction( - icon_name=icon_name, tooltip=tooltip, filled=True, parent=self + icon_name=icon_name, + tooltip=tooltip, + filled=True, + parent=self, + label_text=widget_type, + text_position="under", ), ) bundle.add_action(flat_action_id) @@ -372,7 +367,9 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) spacer_bundle.add_action("spacer") self.toolbar.add_bundle(spacer_bundle) - self.toolbar.add_bundle(workspace_bundle(self.toolbar.components)) + self.toolbar.add_bundle( + workspace_bundle(self.toolbar.components, enable_tools=self._profile_management_enabled) + ) self.toolbar.connect_bundle( "workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self) ) @@ -384,20 +381,22 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self ), ) + self.toolbar.components.get_action("attach_all").action.setVisible( + self._profile_management_enabled + ) 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) + self.toolbar.components.get_action("screenshot").action.setVisible( + self._profile_management_enabled + ) + dark_mode_action = WidgetAction( + widget=self.dark_mode_button, adjust_size=False, parent=self ) - # Developer mode toggle (moved from menu into toolbar) #TODO temporary disable - # self.toolbar.components.add_safe( - # "developer_mode", - # MaterialIconAction( - # icon_name="code", tooltip="Developer Mode", checkable=True, parent=self - # ), - # ) + dark_mode_action.widget.setVisible(self._profile_management_enabled) + self.toolbar.components.add_safe("dark_mode", dark_mode_action) + bda = ToolbarBundle("dock_actions", self.toolbar.components) bda.add_action("attach_all") bda.add_action("screenshot") @@ -405,17 +404,7 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) # bda.add_action("developer_mode") #TODO temporary disable self.toolbar.add_bundle(bda) - # Default bundle configuration (show menus by default) - self.toolbar.show_bundles( - [ - "menu_plots", - "menu_devices", - "menu_utils", - "spacer_bundle", - "workspace", - "dock_actions", - ] - ) + self._apply_toolbar_layout() # Store mappings on self for use in _hook_toolbar self._ACTION_MAPPINGS = { @@ -425,10 +414,11 @@ def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]) } def _hook_toolbar(self): - def _connect_menu(menu_key: str): menu = self.toolbar.components.get_action(menu_key) mapping = self._ACTION_MAPPINGS[menu_key] + + # first two items not needed for this part for key, (_, _, widget_type) in mapping.items(): act = menu.actions[key].action if widget_type == "LogPanel": @@ -443,6 +433,7 @@ def _connect_menu(menu_key: str): widget=t, closable=True, startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}", + show_settings_action=True, ) ) else: @@ -452,197 +443,33 @@ def _connect_menu(menu_key: str): _connect_menu("menu_devices") _connect_menu("menu_utils") - # Connect flat toolbar actions - def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]): + def _connect_flat_actions(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)) + flat_action.triggered.connect(lambda _, t=widget_type: self.new(t)) - _connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"]) - _connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"]) - _connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"]) + _connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"]) + _connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"]) + _connect_flat_actions(self._ACTION_MAPPINGS["menu_utils"]) self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all) self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot) - # Developer mode toggle #TODO temporary disable - # self.toolbar.components.get_action("developer_mode").action.toggled.connect( - # self._on_developer_mode_toggled - # ) def _set_editable(self, editable: bool) -> None: self.lock_workspace = not editable self._editable = editable - 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 #TODO temporary disable - # self.toolbar.components.get_action("developer_mode").action.setChecked(editable) + if self._profile_management_enabled: + self.toolbar.components.get_action("attach_all").action.setVisible(editable) def _on_developer_mode_toggled(self, checked: bool) -> None: """Handle developer mode checkbox toggle.""" self._set_editable(checked) - ################################################################################ - # Adding widgets - ################################################################################ - @SafeSlot(popup_error=True) - def new( - self, - widget: BECWidget | str, - closable: bool = True, - floatable: bool = True, - movable: bool = True, - start_floating: bool = False, - where: Literal["left", "right", "top", "bottom"] | None = None, - **kwargs, - ) -> BECWidget: - """ - Create a new widget (or reuse an instance) and add it as a dock. - - Args: - widget: Widget instance or a string widget type (factory-created). - closable: Whether the dock is closable. - floatable: Whether the dock is floatable. - movable: Whether the dock is movable. - start_floating: Start the dock in a floating state. - where: Preferred area to add the dock: "left" | "right" | "top" | "bottom". - If None, uses the instance default passed at construction time. - **kwargs: The keyword arguments for the widget. - Returns: - The widget instance. - """ - target_area = self._area_from_where(where) - - # 1) Instantiate or look up the widget - if isinstance(widget, str): - widget = cast( - BECWidget, widget_handler.create_widget(widget_type=widget, parent=self, **kwargs) - ) - widget.name_established.connect( - lambda: self._create_dock_with_name( - widget=widget, - closable=closable, - floatable=floatable, - movable=movable, - start_floating=start_floating, - area=target_area, - ) - ) - return widget - - # If a widget instance is passed, dock it immediately - self._create_dock_with_name( - widget=widget, - closable=closable, - floatable=floatable, - movable=movable, - start_floating=start_floating, - area=target_area, - ) - return widget - - def _create_dock_with_name( - self, - widget: BECWidget, - closable: bool = True, - floatable: bool = False, - movable: bool = True, - start_floating: bool = False, - area: QtAds.DockWidgetArea | None = None, - ): - target_area = area or self._area_from_where(None) - self._make_dock( - widget, - closable=closable, - floatable=floatable, - movable=movable, - area=target_area, - start_floating=start_floating, - ) - self.dock_manager.setFocus() - - ################################################################################ - # Dock Management - ################################################################################ - - def dock_map(self) -> dict[str, CDockWidget]: - """ - Return the dock widgets map as dictionary with names as keys and dock widgets as values. - - Returns: - dict: A dictionary mapping widget names to their corresponding dock widgets. - """ - return self.dock_manager.dockWidgetsMap() - - def dock_list(self) -> list[CDockWidget]: - """ - Return the list of dock widgets. - - Returns: - list: A list of all dock widgets in the dock area. - """ - return self.dock_manager.dockWidgets() - - def widget_map(self) -> dict[str, QWidget]: - """ - Return a dictionary mapping widget names to their corresponding BECWidget instances. - - Returns: - dict: A dictionary mapping widget names to BECWidget instances. - """ - return {dock.objectName(): dock.widget() for dock in self.dock_list()} - - def widget_list(self) -> list[QWidget]: - """ - Return a list of all BECWidget instances in the dock area. - - Returns: - list: A list of all BECWidget instances in the dock area. - """ - return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)] - - @SafeSlot() - def attach_all(self): - """ - Return all floating docks to the dock area, preserving tab groups within each floating container. - """ - for container in self.dock_manager.floatingWidgets(): - docks = container.dockWidgets() - if not docks: - continue - target = docks[0] - self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target) - for d in docks[1:]: - self.dock_manager.addDockWidgetTab( - QtAds.DockWidgetArea.RightDockWidgetArea, d, target - ) - - @SafeSlot() - def delete_all(self): - """Delete all docks and widgets.""" - for dock in list(self.dock_manager.dockWidgets()): - self._delete_dock(dock) - ################################################################################ # Workspace Management ################################################################################ @@ -666,16 +493,49 @@ def lock_workspace(self, value: bool): """ self._locked = value self._apply_dock_lock(value) - self.toolbar.components.get_action("save_workspace").action.setVisible(not value) + if self._profile_management_enabled: + self.toolbar.components.get_action("save_workspace").action.setVisible(not value) for dock in self.dock_list(): dock.setting_action.setVisible(not value) + def _resolve_profile_namespace(self) -> str | None: + if self._profile_namespace_resolved is not _PROFILE_NAMESPACE_UNSET: + return self._profile_namespace_resolved # type: ignore[return-value] + + candidate = self._profile_namespace_hint + if self._profile_namespace_auto: + if not candidate: + obj_name = self.objectName() + candidate = obj_name if obj_name else None + if not candidate: + title = self.windowTitle() + candidate = title if title and title.strip() else None + if not candidate: + mode_name = getattr(self, "_mode", None) or "creator" + candidate = f"{mode_name}_workspace" + if not candidate: + candidate = self.__class__.__name__ + + resolved = sanitize_namespace(candidate) if candidate else None + if not resolved: + resolved = "general" + self._profile_namespace_resolved = resolved # type: ignore[assignment] + return resolved + + @property + def profile_namespace(self) -> str | None: + """Namespace used to scope user/default profile files for this dock area.""" + return self._resolve_profile_namespace() + + def _active_profile_name_or_default(self) -> str: + name = getattr(self, "_current_profile_name", None) + if not name: + name = "general" + self._current_profile_name = name + return name + def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: - settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry()) - settings.setValue(SETTINGS_KEYS["state"], b"") - settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState()) - self.dock_manager.addPerspective(self.windowTitle()) - self.dock_manager.savePerspectives(settings) + self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.save_state(settings=settings) write_manifest(settings, self.dock_list()) if save_preview: @@ -702,11 +562,13 @@ def save_profile(self, name: str | None = None): name (str | None): The name of the profile to save. If None, prompts the user. """ + namespace = self.profile_namespace + def _profile_exists(profile_name: str) -> bool: - return profile_origin(profile_name) != "unknown" + return profile_origin(profile_name, namespace=namespace) != "unknown" initial_name = name or "" - quickselect_default = is_quick_select(name) if name else False + quickselect_default = is_quick_select(name, namespace=namespace) if name else False current_profile = getattr(self, "_current_profile_name", "") or "" dialog = SaveProfileDialog( @@ -714,8 +576,8 @@ def _profile_exists(profile_name: str) -> bool: current_name=initial_name, current_profile_name=current_profile, name_exists=_profile_exists, - profile_origin=profile_origin, - origin_label=profile_origin_display, + profile_origin=lambda n: profile_origin(n, namespace=namespace), + origin_label=lambda n: profile_origin_display(n, namespace=namespace), quick_select_checked=quickselect_default, ) if dialog.exec() != QDialog.Accepted: @@ -723,7 +585,7 @@ def _profile_exists(profile_name: str) -> bool: name = dialog.get_profile_name() quickselect = dialog.is_quick_select() - origin_before_save = profile_origin(name) + origin_before_save = profile_origin(name, namespace=namespace) overwrite_default = dialog.overwrite_existing and origin_before_save == "settings" # Display saving placeholder workspace_combo = self.toolbar.components.get_action("workspace_combo").widget @@ -733,9 +595,11 @@ def _profile_exists(profile_name: str) -> bool: workspace_combo.blockSignals(False) # Create or update default copy controlled by overwrite flag - should_write_default = overwrite_default or not os.path.exists(default_profile_path(name)) + should_write_default = overwrite_default or not any( + os.path.exists(path) for path in default_profile_candidates(name, namespace) + ) if should_write_default: - ds = open_default_settings(name) + ds = open_default_settings(name, namespace=namespace) self._write_snapshot_to_settings(ds) if not ds.value(SETTINGS_KEYS["created_at"], ""): ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) @@ -744,7 +608,7 @@ def _profile_exists(profile_name: str) -> bool: ds.setValue(SETTINGS_KEYS["is_quick_select"], False) # Always (over)write the user copy - us = open_user_settings(name) + us = open_user_settings(name, namespace=namespace) self._write_snapshot_to_settings(us) if not us.value(SETTINGS_KEYS["created_at"], ""): us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) @@ -754,7 +618,7 @@ def _profile_exists(profile_name: str) -> bool: # set quick select if quickselect: - set_quick_select(name, quickselect) + set_quick_select(name, quickselect, namespace=namespace) self._refresh_workspace_list() if current_profile and current_profile != name and not dialog.overwrite_existing: @@ -764,7 +628,7 @@ def _profile_exists(profile_name: str) -> bool: workspace_combo.setCurrentText(name) self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name) + set_last_profile(name, namespace=namespace) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -782,21 +646,22 @@ def load_profile(self, name: str | None = None): if not ok or not name: return + namespace = self.profile_namespace prev_name = getattr(self, "_current_profile_name", None) skip_pair = getattr(self, "_pending_autosave_skip", None) if prev_name and prev_name != name: if skip_pair and skip_pair == (prev_name, name): self._pending_autosave_skip = None else: - us_prev = open_user_settings(prev_name) - self._write_snapshot_to_settings(us_prev, save_preview=False) - - # Choose source settings: user first, else default - if os.path.exists(user_profile_path(name)): - settings = open_user_settings(name) - elif os.path.exists(default_profile_path(name)): - settings = open_default_settings(name) - else: + us_prev = open_user_settings(prev_name, namespace=namespace) + self._write_snapshot_to_settings(us_prev, save_preview=True) + + settings = None + if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)): + settings = open_user_settings(name, namespace=namespace) + elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)): + settings = open_default_settings(name, namespace=namespace) + if settings is None: QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.") return @@ -815,19 +680,13 @@ def load_profile(self, name: str | None = None): area=QtAds.DockWidgetArea.RightDockWidgetArea, ) - geom = settings.value(SETTINGS_KEYS["geom"]) - if geom: - self.restoreGeometry(geom) - dock_state = settings.value(SETTINGS_KEYS["ads_state"]) - if dock_state: - self.dock_manager.restoreState(dock_state) - self.dock_manager.loadPerspectives(settings) + self.load_from_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.load_state(settings=settings) self._set_editable(self._editable) self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name) + set_last_profile(name, namespace=namespace) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -844,6 +703,7 @@ def restore_user_profile_from_default(self, name: str | None = None): target = name or getattr(self, "_current_profile_name", None) if not target: return + namespace = self.profile_namespace current_pixmap = None if self.isVisible(): @@ -851,13 +711,13 @@ def restore_user_profile_from_default(self, name: str | None = None): ba = bytes(self.screenshot_bytes()) current_pixmap.loadFromData(ba) if current_pixmap is None or current_pixmap.isNull(): - current_pixmap = load_user_profile_screenshot(target) - default_pixmap = load_default_profile_screenshot(target) + current_pixmap = load_user_profile_screenshot(target, namespace=namespace) + default_pixmap = load_default_profile_screenshot(target, namespace=namespace) if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap): return - restore_user_from_default(target) + restore_user_from_default(target, namespace=namespace) self.delete_all() self.load_profile(target) @@ -871,6 +731,13 @@ def delete_profile(self): if not name: return + # Protect bundled/module/plugin profiles from deletion + if is_profile_read_only(name, namespace=self.profile_namespace): + QMessageBox.information( + self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted." + ) + return + # Confirm deletion for regular profiles reply = QMessageBox.question( self, @@ -883,11 +750,8 @@ def delete_profile(self): if reply != QMessageBox.Yes: return - file_path = user_profile_path(name) - try: - os.remove(file_path) - except FileNotFoundError: - return + namespace = self.profile_namespace + delete_profile_files(name, namespace=namespace) self._refresh_workspace_list() def _refresh_workspace_list(self): @@ -896,17 +760,16 @@ def _refresh_workspace_list(self): """ combo = self.toolbar.components.get_action("workspace_combo").widget active_profile = getattr(self, "_current_profile_name", None) + namespace = self.profile_namespace + if hasattr(combo, "set_quick_profile_provider"): + combo.set_quick_profile_provider(lambda ns=namespace: list_quick_profiles(namespace=ns)) if hasattr(combo, "refresh_profiles"): combo.refresh_profiles(active_profile) else: # Fallback for regular QComboBox - from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( - list_quick_profiles, - ) - combo.blockSignals(True) combo.clear() - quick_profiles = list_quick_profiles() + quick_profiles = list_quick_profiles(namespace=namespace) items = list(quick_profiles) if active_profile and active_profile not in items: items.insert(0, active_profile) @@ -968,46 +831,70 @@ def mode(self) -> str: @mode.setter def mode(self, new_mode: str): - if new_mode not in ["plot", "device", "utils", "developer", "user"]: + allowed_modes = ["plot", "device", "utils", "user", "creator"] + if new_mode not in allowed_modes: 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"]) + self._apply_toolbar_layout() + + def _apply_toolbar_layout(self) -> None: + mode_key = getattr(self, "_mode", "creator") + if mode_key == "user": + bundles = ["spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "creator": + bundles = [ + "menu_plots", + "menu_devices", + "menu_utils", + "spacer_bundle", + "workspace", + "dock_actions", + ] + elif mode_key == "plot": + bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "device": + bundles = ["flat_devices", "spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "utils": + bundles = ["flat_utils", "spacer_bundle", "workspace", "dock_actions"] else: - # Fallback to user mode - self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) + bundles = ["spacer_bundle", "workspace", "dock_actions"] + + if not self._profile_management_enabled: + flat_only = [b for b in bundles if b.startswith("flat_")] + if not flat_only: + flat_only = ["flat_plots", "flat_devices", "flat_utils"] + bundles = flat_only + + self.toolbar.show_bundles(bundles) + + def prepare_for_shutdown(self) -> None: + """ + Persist the current workspace snapshot while the UI is still fully visible. + Called by the main window before initiating widget teardown to avoid capturing + close-triggered visibility changes. + """ + if ( + not self._auto_save_upon_exit + or getattr(self, "_exit_snapshot_written", False) + or getattr(self, "_destroyed", False) + ): + logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)") + return + + name = self._active_profile_name_or_default() + + namespace = self.profile_namespace + settings = open_user_settings(name, namespace=namespace) + self._write_snapshot_to_settings(settings) + set_last_profile(name, namespace=namespace) + self._exit_snapshot_written = True def cleanup(self): """ Cleanup the dock area. """ - # before cleanup save current profile (user copy) - name = getattr(self, "_current_profile_name", None) - if name: - us = open_user_settings(name) - self._write_snapshot_to_settings(us) - set_last_profile(name) + self.prepare_for_shutdown() if self.manage_dialog is not None: self.manage_dialog.reject() self.manage_dialog = None @@ -1025,7 +912,7 @@ def cleanup(self): apply_theme("dark") dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() - ads = AdvancedDockArea(mode="developer", root_widget=True) + ads = AdvancedDockArea(mode="creator", root_widget=True, enable_profile_management=True) window.setCentralWidget(ads) window.show() window.resize(800, 600) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py new file mode 100644 index 000000000..09898569e --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -0,0 +1,1368 @@ +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import Any, Callable, Literal, Mapping, Sequence, cast + +from bec_qthemes import material_icon +from PySide6QtAds import ads +from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget +from shiboken6 import isValid + +import bec_widgets.widgets.containers.ads as QtAds +from bec_widgets import BECWidget, SafeSlot +from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler +from bec_widgets.utils.property_editor import PropertyEditor +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.ads import ( + CDockAreaWidget, + CDockManager, + CDockSplitter, + CDockWidget, +) + + +class DockSettingsDialog(QDialog): + """Generic settings editor shown from dock title bar actions.""" + + def __init__(self, parent: QWidget, target: QWidget): + super().__init__(parent) + self.setWindowTitle("Dock Settings") + self.setModal(True) + layout = QVBoxLayout(self) + self.prop_editor = PropertyEditor(target, self, show_only_bec=True) + layout.addWidget(self.prop_editor) + + +class DockAreaWidget(BECWidget, QWidget): + """ + Lightweight dock area that exposes the core Qt ADS docking helpers without any + of the toolbar or workspace management features that the advanced variant offers. + """ + + RPC = True + PLUGIN = False + USER_ACCESS = [ + "new", + "dock_map", + "dock_list", + "widget_map", + "widget_list", + "attach_all", + "delete_all", + "set_layout_ratios", + "describe_layout", + "print_layout_structure", + "set_central_dock", + ] + + @dataclass + class DockCreationSpec: + widget: QWidget + closable: bool = True + floatable: bool = True + movable: bool = True + start_floating: bool = False + area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea + on_close: Callable[[CDockWidget, QWidget], None] | None = None + tab_with: CDockWidget | None = None + relative_to: CDockWidget | None = None + title_visible: bool | None = None + title_buttons: Mapping[ads.TitleBarButton, bool] | None = None + show_settings_action: bool | None = False + dock_preferences: Mapping[str, Any] | None = None + promote_central: bool = False + dock_icon: QIcon | None = None + apply_widget_icon: bool = True + + def __init__( + self, + parent: QWidget | None = None, + default_add_direction: Literal["left", "right", "top", "bottom"] = "right", + title: str = "Dock Area", + variant: Literal["cards", "compact"] = "cards", + **kwargs, + ): + super().__init__(parent=parent, **kwargs) + + # Set variant property for styling + + if title: + self.setWindowTitle(title) + + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self.dock_manager.setProperty("variant", variant) + + self._locked = False + self._default_add_direction = ( + default_add_direction + if default_add_direction in ("left", "right", "top", "bottom") + else "right" + ) + + self._root_layout.addWidget(self.dock_manager, 1) + + ################################################################################ + # Dock Utility Helpers + ################################################################################ + + def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea: + """Translate a direction string into a Qt ADS dock widget area.""" + direction = (where or self._default_add_direction or "right").lower() + mapping = { + "left": QtAds.DockWidgetArea.LeftDockWidgetArea, + "right": QtAds.DockWidgetArea.RightDockWidgetArea, + "top": QtAds.DockWidgetArea.TopDockWidgetArea, + "bottom": QtAds.DockWidgetArea.BottomDockWidgetArea, + } + return mapping.get(direction, QtAds.DockWidgetArea.RightDockWidgetArea) + + def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None: + """Hook for subclasses to customise the dock before it is shown.""" + prefs: Mapping[str, Any] = getattr(dock, "_dock_preferences", {}) or {} + show_settings = prefs.get("show_settings_action") + if show_settings: + self._install_dock_settings_action(dock, widget) + + def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None: + """Attach a dock-level settings action if available.""" + if getattr(dock, "setting_action", None) is not None: + return + + action = MaterialIconAction( + icon_name="settings", tooltip="Dock settings", filled=True, parent=self + ).action + action.setObjectName("dockSettingsAction") + action.setToolTip("Dock settings") + action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget)) + + existing = list(dock.titleBarActions()) + existing.append(action) + dock.setTitleBarActions(existing) + dock.setting_action = action + + def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None: + """Launch the property editor dialog for the dock's widget.""" + dlg = DockSettingsDialog(self, widget) + dlg.resize(600, 600) + dlg.exec() + + ################################################################################ + # Dock Lifecycle + ################################################################################ + + def _default_close_handler(self, dock: CDockWidget, widget: QWidget) -> None: + """Default dock close routine used when no custom handler is provided.""" + widget.close() + dock.closeDockWidget() + dock.deleteDockWidget() + + def close_dock(self, dock: CDockWidget, widget: QWidget | None = None) -> None: + """ + Helper for custom close handlers to invoke the default close behaviour. + + Args: + dock: Dock widget to close. + widget: Optional widget contained in the dock; resolved automatically when not given. + """ + target_widget = widget or dock.widget() + if target_widget is None: + return + self._default_close_handler(dock, target_widget) + + def _wrap_close_candidate( + self, candidate: Callable, widget: QWidget + ) -> Callable[[CDockWidget], None]: + """ + Wrap a user-provided close handler to adapt its signature. + + Args: + candidate(Callable): User-provided close handler. + widget(QWidget): Widget contained in the dock. + + Returns: + Callable[[CDockWidget], None]: Wrapped close handler. + """ + try: + sig = inspect.signature(candidate) + accepts_varargs = any( + p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values() + ) + positional_params = [ + p + for p in sig.parameters.values() + if p.kind + in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + except (ValueError, TypeError): + accepts_varargs = True + positional_params = [] + + positional_count = len(positional_params) + + def invoke(dock: CDockWidget) -> None: + try: + if accepts_varargs or positional_count >= 2: + candidate(dock, widget) + elif positional_count == 1: + candidate(dock) + else: + candidate() + except TypeError: + # Best effort fallback in case the signature inspection was misleading. + candidate(dock, widget) + + return invoke + + def _resolve_close_handler( + self, widget: QWidget, on_close: Callable[[CDockWidget, QWidget], None] | None = None + ) -> Callable[[CDockWidget], None]: + """ + Determine which close handler to use for a dock. + Priority: + 1. Explicit `on_close` callable passed to `new`. + 2. Widget attribute `handle_dock_close` or `on_dock_close` if callable. + 3. Default close handler. + + Args: + widget(QWidget): The widget contained in the dock. + on_close(Callable[[CDockWidget, QWidget], None] | None): Explicit close handler. + + Returns: + Callable[[CDockWidget], None]: Resolved close handler. + """ + + candidate = on_close + if candidate is None: + candidate = getattr(widget, "handle_dock_close", None) + if candidate is None: + candidate = getattr(widget, "on_dock_close", None) + + if callable(candidate): + return self._wrap_close_candidate(candidate, widget) + + return lambda dock: self._default_close_handler(dock, widget) + + def _make_dock( + self, + widget: QWidget, + *, + closable: bool, + floatable: bool, + movable: bool = True, + area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea, + start_floating: bool = False, + on_close: Callable[[CDockWidget, QWidget], None] | None = None, + tab_with: CDockWidget | None = None, + relative_to: CDockWidget | None = None, + dock_preferences: Mapping[str, Any] | None = None, + promote_central: bool = False, + dock_icon: QIcon | None = None, + apply_widget_icon: bool = True, + ) -> CDockWidget: + """ + Create and add a new dock widget to the area. + + Args: + widget(QWidget): The widget to dock. + closable(bool): Whether the dock can be closed. + floatable(bool): Whether the dock can be floated. + movable(bool): Whether the dock can be moved. + area(QtAds.DockWidgetArea): Target dock area. + start_floating(bool): Whether the dock should start floating. + on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. + tab_with(CDockWidget | None): Optional dock to tab with. + relative_to(CDockWidget | None): Optional dock to position relative to. + dock_preferences(Mapping[str, Any] | None): Appearance preferences to apply. + promote_central(bool): Whether to promote the dock to central widget. + dock_icon(QIcon | None): Explicit icon to use for the dock. + apply_widget_icon(bool): Whether to apply the widget's ICON_NAME as dock icon. + + Returns: + CDockWidget: Created dock widget. + """ + if not widget.objectName(): + widget.setObjectName(widget.__class__.__name__) + + if tab_with is not None and relative_to is not None: + raise ValueError("Specify either 'tab_with' or 'relative_to', not both.") + + dock = CDockWidget(widget.objectName()) + dock.setWidget(widget) + dock._dock_preferences = dict(dock_preferences or {}) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) + dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, closable) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, floatable) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, movable) + + self._customize_dock(dock, widget) + resolved_icon = self._resolve_dock_icon(widget, dock_icon, apply_widget_icon) + + close_handler = self._resolve_close_handler(widget, on_close) + + def on_widget_destroyed(): + if not isValid(dock): + return + dock.closeDockWidget() + dock.deleteDockWidget() + + dock.closeRequested.connect(lambda: close_handler(dock)) + if hasattr(widget, "widget_removed"): + widget.widget_removed.connect(on_widget_destroyed) + + dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget) + dock_area_widget = None + if tab_with is not None: + if not isValid(tab_with): + raise ValueError("Tab target dock widget is not valid anymore.") + dock_area_widget = tab_with.dockAreaWidget() + + if dock_area_widget is not None: + self.dock_manager.addDockWidgetTabToArea(dock, dock_area_widget) + else: + target_area_widget = None + if relative_to is not None: + if not isValid(relative_to): + raise ValueError("Relative target dock widget is not valid anymore.") + target_area_widget = relative_to.dockAreaWidget() + self.dock_manager.addDockWidget(area, dock, target_area_widget) + + if start_floating and tab_with is None and not promote_central: + dock.setFloating() + if resolved_icon is not None: + dock.setIcon(resolved_icon) + return dock + + def _delete_dock(self, dock: CDockWidget) -> None: + widget = dock.widget() + if widget and isValid(widget): + widget.close() + widget.deleteLater() + if isValid(dock): + dock.closeDockWidget() + dock.deleteDockWidget() + + def _resolve_dock_reference( + self, ref: CDockWidget | QWidget | str | None, *, allow_none: bool = True + ) -> CDockWidget | None: + """ + Resolve a dock reference from various input types. + + Args: + ref(CDockWidget | QWidget | str | None): Dock reference. + allow_none(bool): Whether to allow None as a valid return value. + + Returns: + CDockWidget | None: Resolved dock widget or None. + """ + if ref is None: + if allow_none: + return None + raise ValueError("Dock reference cannot be None.") + if isinstance(ref, CDockWidget): + if not isValid(ref): + raise ValueError("Dock widget reference is not valid anymore.") + return ref + if isinstance(ref, QWidget): + for dock in self.dock_list(): + if dock.widget() is ref: + return dock + raise ValueError("Widget reference is not associated with any dock in this area.") + if isinstance(ref, str): + dock_map = self.dock_map() + dock = dock_map.get(ref) + if dock is None: + raise ValueError(f"No dock found with objectName '{ref}'.") + return dock + raise TypeError( + "Dock reference must be a CDockWidget, QWidget, object name string, or None." + ) + + ################################################################################ + # Splitter Handling + ################################################################################ + + def _resolve_dock_icon( + self, widget: QWidget, dock_icon: QIcon | None, apply_widget_icon: bool + ) -> QIcon | None: + """ + Choose an icon for the dock: prefer an explicitly provided one, otherwise + fall back to the widget's `ICON_NAME` (material icons) when available. + + Args: + widget(QWidget): The widget to dock. + dock_icon(QIcon | None): Explicit icon to use for the dock. + + Returns: + QIcon | None: Resolved dock icon, or None if not available. + """ + + if dock_icon is not None: + return dock_icon + if not apply_widget_icon: + return None + icon_name = getattr(widget, "ICON_NAME", None) + if not icon_name: + return None + try: + return material_icon(icon_name, size=(24, 24), convert_to_pixmap=False) + except Exception: + return None + + def _build_creation_spec( + self, + widget: QWidget, + *, + closable: bool, + floatable: bool, + movable: bool, + start_floating: bool, + where: Literal["left", "right", "top", "bottom"] | None, + on_close: Callable[[CDockWidget, QWidget], None] | None, + tab_with: CDockWidget | QWidget | str | None, + relative_to: CDockWidget | QWidget | str | None, + show_title_bar: bool | None, + title_buttons: Mapping[str, bool] | Sequence[str] | str | None, + show_settings_action: bool | None, + promote_central: bool, + dock_icon: QIcon | None, + apply_widget_icon: bool, + ) -> DockCreationSpec: + """ + Normalize and validate dock creation parameters into a spec object. + + Args: + widget(QWidget): The widget to dock. + closable(bool): Whether the dock can be closed. + floatable(bool): Whether the dock can be floated. + movable(bool): Whether the dock can be moved. + start_floating(bool): Whether the dock should start floating. + where(Literal["left", "right", "top", "bottom"] | None): Target dock area. + on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. + tab_with(CDockWidget | QWidget | str | None): Optional dock to tab with. + relative_to(CDockWidget | QWidget | str | None): Optional dock to position relative to. + show_title_bar(bool | None): Whether to show the dock title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Title bar buttons to show/hide. + show_settings_action(bool | None): Whether to show the dock settings action. + promote_central(bool): Whether to promote the dock to central widget. + dock_icon(QIcon | None): Explicit icon to use for the dock. + apply_widget_icon(bool): Whether to apply the widget's ICON_NAME as dock icon. + + Returns: + DockCreationSpec: Normalized dock creation specification. + + """ + normalized_buttons = self._normalize_title_buttons(title_buttons) + resolved_tab = self._resolve_dock_reference(tab_with) + resolved_relative = self._resolve_dock_reference(relative_to) + + if resolved_tab is not None and resolved_relative is not None: + raise ValueError("Specify either 'tab_with' or 'relative_to', not both.") + + target_area = self._area_from_where(where) + if resolved_relative is not None and where is None: + inferred = self.dock_manager.dockWidgetArea(resolved_relative) + if inferred in ( + QtAds.DockWidgetArea.InvalidDockWidgetArea, + QtAds.DockWidgetArea.NoDockWidgetArea, + ): + inferred = self._area_from_where(None) + target_area = inferred + + dock_preferences = { + "show_title_bar": show_title_bar, + "title_buttons": normalized_buttons if normalized_buttons else None, + "show_settings_action": show_settings_action, + } + dock_preferences = {k: v for k, v in dock_preferences.items() if v is not None} + + return self.DockCreationSpec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + area=target_area, + on_close=on_close, + tab_with=resolved_tab, + relative_to=resolved_relative, + title_visible=show_title_bar, + title_buttons=normalized_buttons if normalized_buttons else None, + show_settings_action=show_settings_action, + dock_preferences=dock_preferences or None, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + + def _create_dock_from_spec(self, spec: DockCreationSpec) -> CDockWidget: + """ + Create a dock from a normalized spec and apply preferences. + + Args: + spec(DockCreationSpec): Dock creation specification. + + Returns: + CDockWidget: Created dock widget. + """ + dock = self._make_dock( + spec.widget, + closable=spec.closable, + floatable=spec.floatable, + movable=spec.movable, + area=spec.area, + start_floating=spec.start_floating, + on_close=spec.on_close, + tab_with=spec.tab_with, + relative_to=spec.relative_to, + dock_preferences=spec.dock_preferences, + promote_central=spec.promote_central, + dock_icon=spec.dock_icon, + apply_widget_icon=spec.apply_widget_icon, + ) + self.dock_manager.setFocus() + self._apply_dock_preferences(dock) + if spec.promote_central: + self.set_central_dock(dock) + return dock + + def _coerce_weights( + self, + weights: Sequence[float] | Mapping[int | str, float] | None, + count: int, + orientation: Qt.Orientation, + ) -> list[float] | None: + """ + Normalize weight specs into a list matching splitter child count. + + Args: + weights(Sequence[float] | Mapping[int | str, float] | None): Weight specification. + count(int): Number of splitter children. + orientation(Qt.Orientation): Splitter orientation. + + Returns: + list[float] | None: Normalized weight list, or None if invalid. + """ + if weights is None or count <= 0: + return None + + result: list[float] + if isinstance(weights, (list, tuple)): + result = [float(v) for v in weights[:count]] + elif isinstance(weights, Mapping): + default = float(weights.get("default", 1.0)) + result = [default] * count + + alias: dict[str, int] = {} + if count >= 1: + alias["first"] = 0 + alias["start"] = 0 + if count >= 2: + alias["last"] = count - 1 + alias["end"] = count - 1 + if orientation == Qt.Orientation.Horizontal: + alias["left"] = 0 + alias["right"] = count - 1 + if count >= 3: + alias["center"] = count // 2 + alias["middle"] = count // 2 + else: + alias["top"] = 0 + alias["bottom"] = count - 1 + + for key, value in weights.items(): + if key == "default": + continue + idx: int | None = None + if isinstance(key, int): + idx = key + elif isinstance(key, str): + lowered = key.lower() + if lowered in alias: + idx = alias[lowered] + elif lowered.startswith("col"): + try: + idx = int(lowered[3:]) + except ValueError: + idx = None + elif lowered.startswith("row"): + try: + idx = int(lowered[3:]) + except ValueError: + idx = None + if idx is not None and 0 <= idx < count: + result[idx] = float(value) + else: + return None + + if len(result) < count: + result += [1.0] * (count - len(result)) + result = result[:count] + if all(v <= 0 for v in result): + result = [1.0] * count + return result + + def _schedule_splitter_weights( + self, + splitter: QtAds.CDockSplitter, + weights: Sequence[float] | Mapping[int | str, float] | None, + ) -> None: + """ + Apply weight ratios to a splitter once geometry is available. + + Args: + splitter(QtAds.CDockSplitter): Target splitter. + weights(Sequence[float] | Mapping[int | str, float] | None): Weight specification. + """ + if splitter is None or weights is None: + return + + ratios = self._coerce_weights(weights, splitter.count(), splitter.orientation()) + if not ratios: + return + + def apply(): + count = splitter.count() + if count != len(ratios): + return + + orientation = splitter.orientation() + total_px = ( + splitter.width() if orientation == Qt.Orientation.Horizontal else splitter.height() + ) + if total_px <= count: + QTimer.singleShot(0, apply) + return + + total = sum(ratios) + if total <= 0: + return + sizes = [max(1, int(round(total_px * (r / total)))) for r in ratios] + diff = total_px - sum(sizes) + if diff: + idx = max(range(count), key=lambda i: ratios[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, weight in enumerate(ratios): + splitter.setStretchFactor(i, max(1, int(round(weight * 100)))) + + QTimer.singleShot(0, apply) + + def _normalize_override_keys( + self, + overrides: Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]], + ) -> dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]]: + """ + Normalize various key types into tuple paths. + + Args: + overrides(Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]]): + Original overrides mapping. + + Returns: + dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]]: + Normalized overrides mapping. + """ + normalized: dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]] = {} + for key, value in overrides.items(): + path: tuple[int, ...] | None = None + if isinstance(key, int): + path = (key,) + elif isinstance(key, (list, tuple)): + try: + path = tuple(int(k) for k in key) + except ValueError: + continue + elif isinstance(key, str): + cleaned = key.replace(" ", "").replace(".", "/") + if cleaned in ("", "/"): + path = () + else: + parts = [p for p in cleaned.split("/") if p] + try: + path = tuple(int(p) for p in parts) + except ValueError: + continue + if path is not None: + normalized[path] = value + return normalized + + def _apply_splitter_tree( + self, + splitter: QtAds.CDockSplitter, + path: tuple[int, ...], + horizontal: Sequence[float] | Mapping[int | str, float] | None, + vertical: Sequence[float] | Mapping[int | str, float] | None, + overrides: dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]], + ) -> None: + """Traverse splitter hierarchy and apply ratios.""" + orientation = splitter.orientation() + base_weights = horizontal if orientation == Qt.Orientation.Horizontal else vertical + + override = None + if overrides: + if path in overrides: + override = overrides[path] + elif len(path) >= 1: + key = (path[-1],) + if key in overrides: + override = overrides[key] + + self._schedule_splitter_weights(splitter, override or base_weights) + + for idx in range(splitter.count()): + child = splitter.widget(idx) + if isinstance(child, QtAds.CDockSplitter): + self._apply_splitter_tree(child, path + (idx,), horizontal, vertical, overrides) + + ################################################################################ + # Layout Inspection + ################################################################################ + + def _collect_splitter_info( + self, + splitter: CDockSplitter, + path: tuple[int, ...], + results: list[dict[str, Any]], + container_index: int, + ) -> None: + orientation = ( + "horizontal" if splitter.orientation() == Qt.Orientation.Horizontal else "vertical" + ) + entry: dict[str, Any] = { + "container": container_index, + "path": path, + "orientation": orientation, + "children": [], + } + results.append(entry) + + for idx in range(splitter.count()): + child = splitter.widget(idx) + if isinstance(child, CDockSplitter): + entry["children"].append({"index": idx, "type": "splitter"}) + self._collect_splitter_info(child, path + (idx,), results, container_index) + elif isinstance(child, CDockAreaWidget): + docks = [dock.objectName() for dock in child.dockWidgets()] + entry["children"].append({"index": idx, "type": "dock_area", "docks": docks}) + elif isinstance(child, CDockWidget): + entry["children"].append({"index": idx, "type": "dock", "name": child.objectName()}) + else: + entry["children"].append({"index": idx, "type": child.__class__.__name__}) + + def describe_layout(self) -> list[dict[str, Any]]: + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + info: list[dict[str, Any]] = [] + for container_index, container in enumerate(self.dock_manager.dockContainers()): + splitter = container.rootSplitter() + if splitter is None: + continue + self._collect_splitter_info(splitter, (), info, container_index) + return info + + def print_layout_structure(self) -> None: + """Pretty-print the current splitter paths to stdout.""" + for entry in self.describe_layout(): + children_desc = [] + for child in entry["children"]: + if child["type"] == "dock_area": + children_desc.append( + f"{child['index']}:dock_area[{', '.join(child['docks']) or '-'}]" + ) + elif child["type"] == "dock": + children_desc.append(f"{child['index']}:dock({child['name']})") + else: + children_desc.append(f"{child['index']}:{child['type']}") + summary = ", ".join(children_desc) + print( + f"container={entry['container']} path={entry['path']} " + f"orientation={entry['orientation']} -> [{summary}]" + ) + + ################################################################################ + # State Persistence + ################################################################################ + + @staticmethod + def _coerce_byte_array(value: Any) -> QByteArray | None: + """Best-effort conversion of arbitrary values into a QByteArray.""" + if isinstance(value, QByteArray): + return QByteArray(value) + if isinstance(value, (bytes, bytearray, memoryview)): + return QByteArray(bytes(value)) + return None + + @staticmethod + def _settings_keys(overrides: Mapping[str, str | None] | None = None) -> dict[str, str | None]: + """ + Merge caller overrides with sensible defaults. + + Only `geom`, `state`, and `ads_state` are recognised. Missing entries default to: + geom -> "dock_area/geometry" + state -> None (skip writing legacy main window state) + ads_state -> "dock_area/docking_state" + """ + defaults: dict[str, str | None] = { + "geom": "dock_area/geometry", + "state": None, + "ads_state": "dock_area/docking_state", + } + if overrides: + for key, value in overrides.items(): + if key in defaults: + defaults[key] = value + return defaults + + def save_to_settings( + self, + settings: QSettings, + *, + keys: Mapping[str, str | None] | None = None, + include_perspectives: bool = True, + perspective_name: str | None = None, + ) -> None: + """ + Persist the current dock layout into an existing `QSettings` instance. + + Args: + settings(QSettings): Target QSettings store (must outlive this call). + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + include_perspectives(bool): When True, save Qt ADS perspectives alongside the layout. + perspective_name(str | None): Optional explicit name for the saved perspective. + """ + resolved = self._settings_keys(keys) + + geom_key = resolved.get("geom") + if geom_key: + settings.setValue(geom_key, self.saveGeometry()) + + legacy_state_key = resolved.get("state") + if legacy_state_key: + settings.setValue(legacy_state_key, b"") + + ads_state_key = resolved.get("ads_state") + if ads_state_key: + settings.setValue(ads_state_key, self.dock_manager.saveState()) + + if include_perspectives: + name = perspective_name or self.windowTitle() + if name: + self.dock_manager.addPerspective(name) + self.dock_manager.savePerspectives(settings) + + def save_to_file( + self, + path: str, + *, + format: QSettings.Format = QSettings.IniFormat, + keys: Mapping[str, str | None] | None = None, + include_perspectives: bool = True, + perspective_name: str | None = None, + ) -> None: + """ + Convenience wrapper around `save_to_settings` that opens a temporary QSettings. + + Args: + path(str): File path to save the settings to. + format(QSettings.Format): File format to use. + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + include_perspectives(bool): When True, save Qt ADS perspectives alongside the layout. + perspective_name(str | None): Optional explicit name for the saved perspective. + """ + settings = QSettings(path, format) + self.save_to_settings( + settings, + keys=keys, + include_perspectives=include_perspectives, + perspective_name=perspective_name, + ) + settings.sync() + + def load_from_settings( + self, + settings: QSettings, + *, + keys: Mapping[str, str | None] | None = None, + restore_perspectives: bool = True, + ) -> None: + """ + Restore the dock layout from a `QSettings` instance previously populated by `save_to_settings`. + + Args: + settings(QSettings): Source QSettings store (must outlive this call). + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + restore_perspectives(bool): When True, restore Qt ADS perspectives alongside the layout. + """ + resolved = self._settings_keys(keys) + + geom_key = resolved.get("geom") + if geom_key: + geom_value = settings.value(geom_key) + geom_bytes = self._coerce_byte_array(geom_value) + if geom_bytes is not None: + self.restoreGeometry(geom_bytes) + + ads_state_key = resolved.get("ads_state") + if ads_state_key: + dock_state = settings.value(ads_state_key) + dock_bytes = self._coerce_byte_array(dock_state) + if dock_bytes is not None: + self.dock_manager.restoreState(dock_bytes) + + if restore_perspectives: + self.dock_manager.loadPerspectives(settings) + + def load_from_file( + self, + path: str, + *, + format: QSettings.Format = QSettings.IniFormat, + keys: Mapping[str, str | None] | None = None, + restore_perspectives: bool = True, + ) -> None: + """ + Convenience wrapper around `load_from_settings` that reads from a file path. + """ + settings = QSettings(path, format) + self.load_from_settings(settings, keys=keys, restore_perspectives=restore_perspectives) + + def set_layout_ratios( + self, + *, + horizontal: Sequence[float] | Mapping[int | str, float] | None = None, + vertical: Sequence[float] | Mapping[int | str, float] | None = None, + splitter_overrides: ( + Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None + ) = None, + ) -> None: + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + overrides = self._normalize_override_keys(splitter_overrides) if splitter_overrides else {} + + for container in self.dock_manager.dockContainers(): + splitter = container.rootSplitter() + if splitter is None: + continue + self._apply_splitter_tree(splitter, (), horizontal, vertical, overrides) + + @staticmethod + def _title_bar_button_enum(name: str) -> ads.TitleBarButton | None: + """Translate a user-friendly button name into an ADS TitleBarButton enum.""" + normalized = (name or "").lower().replace("-", "_").replace(" ", "_") + mapping: dict[str, ads.TitleBarButton] = { + "menu": ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs_menu": ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs": ads.TitleBarButton.TitleBarButtonTabsMenu, + "undock": ads.TitleBarButton.TitleBarButtonUndock, + "float": ads.TitleBarButton.TitleBarButtonUndock, + "detach": ads.TitleBarButton.TitleBarButtonUndock, + "close": ads.TitleBarButton.TitleBarButtonClose, + "auto_hide": ads.TitleBarButton.TitleBarButtonAutoHide, + "autohide": ads.TitleBarButton.TitleBarButtonAutoHide, + "minimize": ads.TitleBarButton.TitleBarButtonMinimize, + } + return mapping.get(normalized) + + def _normalize_title_buttons( + self, + spec: ( + Mapping[str | ads.TitleBarButton, bool] + | Sequence[str | ads.TitleBarButton] + | str + | ads.TitleBarButton + | None + ), + ) -> dict[ads.TitleBarButton, bool]: + """Normalize button visibility specifications into an enum mapping.""" + if spec is None: + return {} + + result: dict[ads.TitleBarButton, bool] = {} + if isinstance(spec, Mapping): + iterator = spec.items() + else: + if isinstance(spec, str): + spec = [spec] + iterator = ((name, False) for name in spec) + + for name, visible in iterator: + if isinstance(name, ads.TitleBarButton): + enum = name + else: + enum = self._title_bar_button_enum(str(name)) + if enum is None: + continue + result[enum] = bool(visible) + return result + + def _apply_dock_preferences(self, dock: CDockWidget) -> None: + """ + Apply deferred appearance preferences to a dock once it has been created. + + Args: + dock(CDockWidget): Target dock widget. + """ + prefs: Mapping[str, Any] = getattr(dock, "_dock_preferences", {}) + + def apply(): + title_bar = None + area_widget = dock.dockAreaWidget() + if area_widget is not None and hasattr(area_widget, "titleBar"): + title_bar = area_widget.titleBar() + + show_title_bar = prefs.get("show_title_bar") + if title_bar is not None and show_title_bar is not None: + title_bar.setVisible(bool(show_title_bar)) + + button_prefs = prefs.get("title_buttons") or {} + if title_bar is not None and button_prefs: + for enum, visible in button_prefs.items(): + try: + button = title_bar.button(enum) + except Exception: # pragma: no cover - defensive against ADS API changes + button = None + if button is not None: + button.setVisible(bool(visible)) + + # single shot to ensure dock is fully initialized, as widgets with their own dock manager can take a moment to initialize + QTimer.singleShot(0, apply) + + def set_central_dock(self, dock: CDockWidget | QWidget | str) -> None: + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. + """ + resolved = self._resolve_dock_reference(dock, allow_none=False) + self.dock_manager.setCentralWidget(resolved) + self._apply_dock_preferences(resolved) + + ################################################################################ + # Public API + ################################################################################ + + @SafeSlot(popup_error=True) + def new( + self, + widget: QWidget | str, + *, + closable: bool = True, + floatable: bool = True, + movable: bool = True, + start_floating: bool = False, + where: Literal["left", "right", "top", "bottom"] | None = None, + on_close: Callable[[CDockWidget, QWidget], None] | None = None, + tab_with: CDockWidget | QWidget | str | None = None, + relative_to: CDockWidget | QWidget | str | None = None, + return_dock: bool = False, + show_title_bar: bool | None = None, + title_buttons: Mapping[str, bool] | Sequence[str] | str | None = None, + show_settings_action: bool | None = False, + promote_central: bool = False, + dock_icon: QIcon | None = None, + apply_widget_icon: bool = True, + **widget_kwargs, + ) -> QWidget | CDockWidget | BECWidget: + """ + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + return_dock(bool): When True, return the created dock instead of the widget. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + the widget's ``ICON_NAME`` attribute is used when available. + apply_widget_icon(bool): When False, skip automatically resolving the icon from + the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + + Returns: + The widget instance by default, or the created `CDockWidget` when `return_dock` is True. + """ + if isinstance(widget, str): + if return_dock: + raise ValueError( + "return_dock=True is not supported when creating widgets by type name." + ) + widget = cast( + BECWidget, + widget_handler.create_widget(widget_type=widget, parent=self, **widget_kwargs), + ) + + spec = self._build_creation_spec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + where=where, + on_close=on_close, + tab_with=tab_with, + relative_to=relative_to, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + + def _on_name_established(_name: str) -> None: + # Defer creation so BECConnector sibling name enforcement has completed. + QTimer.singleShot(0, lambda: self._create_dock_from_spec(spec)) + + widget.name_established.connect(_on_name_established) + return widget + + spec = self._build_creation_spec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + where=where, + on_close=on_close, + tab_with=tab_with, + relative_to=relative_to, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + dock = self._create_dock_from_spec(spec) + return dock if return_dock else widget + + def dock_map(self) -> dict[str, CDockWidget]: + """Return the dock widgets map as dictionary with names as keys.""" + return self.dock_manager.dockWidgetsMap() + + def dock_list(self) -> list[CDockWidget]: + """Return the list of dock widgets.""" + return self.dock_manager.dockWidgets() + + def widget_map(self) -> dict[str, QWidget]: + """Return a dictionary mapping widget names to their corresponding widgets.""" + return {dock.objectName(): dock.widget() for dock in self.dock_list()} + + def widget_list(self) -> list[QWidget]: + """Return a list of all widgets contained in the dock area.""" + return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)] + + @SafeSlot() + def attach_all(self): + """Re-attach floating docks back into the dock manager.""" + 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 dock in docks[1:]: + self.dock_manager.addDockWidgetTab( + QtAds.DockWidgetArea.RightDockWidgetArea, dock, target + ) + + @SafeSlot() + def delete_all(self): + """Delete all docks and their associated widgets.""" + for dock in list(self.dock_manager.dockWidgets()): + self._delete_dock(dock) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton + + from bec_widgets.utils.colors import apply_theme + + class CustomCloseWidget(QWidget): + """Example widget showcasing custom close handling via handle_dock_close.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("CustomCloseWidget") + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Custom close handler – tabbed with Column 1 / Row 1.\n" + "Close this dock to see the stdout cleanup message.", + self, + ) + ) + btn = QPushButton("Click me before closing", self) + layout.addWidget(btn) + + def handle_dock_close(self, dock: CDockWidget, widget: QWidget) -> None: + print(f"[CustomCloseWidget] Closing {widget.objectName()}") + area = widget.parent() + while area is not None and not isinstance(area, DockAreaWidget): + area = area.parent() + if isinstance(area, DockAreaWidget): + area.close_dock(dock, widget) + + class LambdaCloseWidget(QWidget): + """Example widget that relies on an explicit lambda passed to BasicDockArea.new.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("LambdaCloseWidget") + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Custom lambda close handler – tabbed with Column 2 / Row 1.\n" + "Closing prints which dock triggered the callback.", + self, + ) + ) + + app = QApplication(sys.argv) + apply_theme("dark") + window = QMainWindow() + area = DockAreaWidget(root_widget=True, title="Basic Dock Area Demo") + window.setCentralWidget(area) + window.resize(1400, 800) + window.show() + + def make_panel(name: str, title: str, body: str = "") -> QWidget: + panel = QWidget() + panel.setObjectName(name) + layout = QVBoxLayout(panel) + layout.addWidget(QLabel(title, panel)) + if body: + layout.addWidget(QLabel(body, panel)) + layout.addStretch(1) + return panel + + # Column 1: plain 'where' usage + col1_top = area.new( + make_panel("C1R1", "Column 1 / Row 1", "Added with where='left'."), + closable=True, + where="left", + return_dock=True, + show_settings_action=True, + ) + area.new( + make_panel("C1R2", "Column 1 / Row 2", "Stacked via relative_to + where='bottom'."), + closable=True, + where="bottom", + relative_to=col1_top, + ) + + # Column 2: relative placement and tabbing + col2_top = area.new( + make_panel( + "C2R1", "Column 2 / Row 1", "Placed to the right of Column 1 using relative_to." + ), + closable=True, + where="right", + relative_to=col1_top, + return_dock=True, + ) + area.new( + make_panel("C2R2", "Column 2 / Row 2", "Added beneath Column 2 / Row 1 via relative_to."), + closable=True, + where="bottom", + relative_to=col2_top, + ) + area.new( + make_panel("C2Tabbed", "Column 2 / Tabbed", "Tabbed with Column 2 / Row 1 using tab_with."), + closable=True, + tab_with=col2_top, + ) + + # Column 3: mix of where, relative_to, and custom close handler + col3_top = area.new( + make_panel("C3R1", "Column 3 / Row 1", "Placed to the right of Column 2 via relative_to."), + closable=True, + where="right", + relative_to=col2_top, + return_dock=True, + ) + area.new( + make_panel( + "C3R2", "Column 3 / Row 2", "Plain where='bottom' relative to Column 3 / Row 1." + ), + closable=True, + where="bottom", + relative_to=col3_top, + ) + area.new( + make_panel( + "C3Lambda", + "Column 3 / Tabbed Lambda", + "Tabbed with Column 3 / Row 1. Custom close handler prints the dock name.", + ), + closable=True, + tab_with=col3_top, + on_close=lambda dock, widget: ( + print(f"[Lambda handler] Closing {widget.objectName()}"), + area.close_dock(dock, widget), + ), + show_settings_action=True, + ) + + area.set_layout_ratios( + horizontal=[1, 1.5, 1], splitter_overrides={0: [3, 2], 1: [4, 3], 2: [2, 1]} + ) + + print("\nSplitter structure (paths for splitter_overrides):") + area.print_layout_structure() + + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index a49183ffe..59c4847bd 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -10,11 +10,13 @@ from __future__ import annotations import os +import re import shutil from functools import lru_cache from pathlib import Path from typing import Literal +from bec_lib import bec_logger from bec_lib.client import BECClient from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from pydantic import BaseModel, Field @@ -22,18 +24,32 @@ from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt from qtpy.QtGui import QPixmap +logger = bec_logger.logger + MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) ProfileOrigin = Literal["module", "plugin", "settings", "unknown"] def module_profiles_dir() -> str: - """Return the read-only module-bundled profiles directory (no writes here).""" + """ + Return the built-in AdvancedDockArea profiles directory bundled with the module. + + Returns: + str: Absolute path of the read-only module profiles directory. + """ return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles") @lru_cache(maxsize=1) def _plugin_repo_root() -> Path | None: + """ + Resolve the plugin repository root path if running inside a plugin context. + + Returns: + Path | None: Root path of the active plugin repository, or ``None`` when + no plugin context is detected. + """ try: return Path(plugin_repo_path()) except ValueError: @@ -42,6 +58,13 @@ def _plugin_repo_root() -> Path | None: @lru_cache(maxsize=1) def _plugin_display_name() -> str | None: + """ + Determine a user-friendly plugin name for provenance labels. + + Returns: + str | None: Human-readable name inferred from the plugin repo or package, + or ``None`` if it cannot be determined. + """ repo_root = _plugin_repo_root() if not repo_root: return None @@ -57,7 +80,13 @@ def _plugin_display_name() -> str | None: @lru_cache(maxsize=1) def plugin_profiles_dir() -> str | None: - """Return the read-only plugin-bundled profiles directory if available.""" + """ + Locate the read-only profiles directory shipped with a beamline plugin. + + Returns: + str | None: Directory containing bundled plugin profiles, or ``None`` if + no plugin profiles are available. + """ repo_root = _plugin_repo_root() if not repo_root: return None @@ -66,8 +95,8 @@ def plugin_profiles_dir() -> str | None: try: package_root = repo_root.joinpath(*plugin_package_name().split(".")) candidates.append(package_root.joinpath("bec_widgets", "profiles")) - except ValueError: - pass + except ValueError as e: + logger.error(f"Could not determine plugin package name: {e}") for candidate in candidates: if candidate.is_dir(): @@ -76,7 +105,12 @@ def plugin_profiles_dir() -> str | None: def _settings_profiles_root() -> str: - """Return the writable profiles root provided by BEC client (or env fallback).""" + """ + Resolve the writable profiles root provided by the BEC client. + + Returns: + str: Absolute path to the profiles root. The directory is created if missing. + """ client = BECClient() bec_widgets_settings = client._service_config.config.get("bec_widgets_settings") bec_widgets_setting_path = ( @@ -88,63 +122,282 @@ def _settings_profiles_root() -> str: return root -def default_profiles_dir() -> str: - path = os.path.join(_settings_profiles_root(), "default") - os.makedirs(path, exist_ok=True) - return path +def sanitize_namespace(namespace: str | None) -> str | None: + """ + Clean user-provided namespace labels for filesystem compatibility. + + Args: + namespace (str | None): Arbitrary namespace identifier supplied by the caller. + + Returns: + str | None: Sanitized namespace containing only safe characters, or ``None`` + when the input is empty. + """ + if not namespace: + return None + ns = namespace.strip() + if not ns: + return None + return re.sub(r"[^0-9A-Za-z._-]+", "_", ns) -def user_profiles_dir() -> str: - path = os.path.join(_settings_profiles_root(), "user") +def _profiles_dir(segment: str, namespace: str | None) -> str: + """ + Build (and ensure) the directory that holds profiles for a namespace segment. + + Args: + segment (str): Either ``"user"`` or ``"default"``. + namespace (str | None): Optional namespace label to scope profiles. + + Returns: + str: Absolute directory path for the requested segment/namespace pair. + """ + base = os.path.join(_settings_profiles_root(), segment) + ns = sanitize_namespace(namespace) + path = os.path.join(base, ns) if ns else base os.makedirs(path, exist_ok=True) return path -def default_profile_path(name: str) -> str: - return os.path.join(default_profiles_dir(), f"{name}.ini") +def _user_path_candidates(name: str, namespace: str | None) -> list[str]: + """ + Generate candidate user-profile paths honoring namespace fallbacks. + + Args: + name (str): Profile name without extension. + namespace (str | None): Optional namespace label. + + Returns: + list[str]: Ordered list of candidate user profile paths (.ini files). + """ + ns = sanitize_namespace(namespace) + primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini") + if not ns: + return [primary] + legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini") + return [primary, legacy] if legacy != primary else [primary] + + +def _default_path_candidates(name: str, namespace: str | None) -> list[str]: + """ + Generate candidate default-profile paths honoring namespace fallbacks. + + Args: + name (str): Profile name without extension. + namespace (str | None): Optional namespace label. + + Returns: + list[str]: Ordered list of candidate default profile paths (.ini files). + """ + ns = sanitize_namespace(namespace) + primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini") + if not ns: + return [primary] + legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini") + return [primary, legacy] if legacy != primary else [primary] + + +def default_profiles_dir(namespace: str | None = None) -> str: + """ + Return the directory that stores default profiles for the namespace. + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. -def user_profile_path(name: str) -> str: - return os.path.join(user_profiles_dir(), f"{name}.ini") + Returns: + str: Absolute path to the default profile directory. + """ + return _profiles_dir("default", namespace) + + +def user_profiles_dir(namespace: str | None = None) -> str: + """ + Return the directory that stores user profiles for the namespace. + + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the user profile directory. + """ + return _profiles_dir("user", namespace) + + +def default_profile_path(name: str, namespace: str | None = None) -> str: + """ + Compute the canonical default profile path for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the default profile file (.ini). + """ + return _default_path_candidates(name, namespace)[0] + + +def user_profile_path(name: str, namespace: str | None = None) -> str: + """ + Compute the canonical user profile path for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str: Absolute path to the user profile file (.ini). + """ + return _user_path_candidates(name, namespace)[0] + + +def user_profile_candidates(name: str, namespace: str | None = None) -> list[str]: + """ + List all user profile path candidates for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[str]: De-duplicated list of candidate user profile paths. + """ + return list(dict.fromkeys(_user_path_candidates(name, namespace))) + + +def default_profile_candidates(name: str, namespace: str | None = None) -> list[str]: + """ + List all default profile path candidates for a profile name. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[str]: De-duplicated list of candidate default profile paths. + """ + return list(dict.fromkeys(_default_path_candidates(name, namespace))) + + +def _existing_user_settings(name: str, namespace: str | None = None) -> QSettings | None: + """ + Resolve the first existing user profile settings object. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to search. Defaults to ``None``. + + Returns: + QSettings | None: Config for the first existing user profile candidate, or ``None`` + when no files are present. + """ + for path in user_profile_candidates(name, namespace): + if os.path.exists(path): + return QSettings(path, QSettings.IniFormat) + return None + + +def _existing_default_settings(name: str, namespace: str | None = None) -> QSettings | None: + """ + Resolve the first existing default profile settings object. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to search. Defaults to ``None``. + + Returns: + QSettings | None: Config for the first existing default profile candidate, or ``None`` + when no files are present. + """ + for path in default_profile_candidates(name, namespace): + if os.path.exists(path): + return QSettings(path, QSettings.IniFormat) + return None def module_profile_path(name: str) -> str: + """ + Build the absolute path to a bundled module profile. + + Args: + name (str): Profile name without extension. + + Returns: + str: Absolute path to the module's read-only profile file. + """ return os.path.join(module_profiles_dir(), f"{name}.ini") def plugin_profile_path(name: str) -> str | None: + """ + Build the absolute path to a bundled plugin profile if available. + + Args: + name (str): Profile name without extension. + + Returns: + str | None: Absolute plugin profile path, or ``None`` when plugins do not + provide profiles. + """ directory = plugin_profiles_dir() if not directory: return None return os.path.join(directory, f"{name}.ini") -def profile_origin(name: str) -> ProfileOrigin: +def profile_origin(name: str, namespace: str | None = None) -> ProfileOrigin: """ Determine where a profile originates from. + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to consider. Defaults to ``None``. + Returns: - ProfileOrigin: "module" for bundled BEC profiles, "plugin" for beamline plugin bundles, - "settings" for user-defined ones, and "unknown" if no backing files are found. + ProfileOrigin: ``"module"`` for bundled BEC profiles, ``"plugin"`` for beamline + plugin bundles, ``"settings"`` for writable copies, and ``"unknown"`` when + no backing files are found. """ if os.path.exists(module_profile_path(name)): return "module" plugin_path = plugin_profile_path(name) if plugin_path and os.path.exists(plugin_path): return "plugin" - if os.path.exists(user_profile_path(name)) or os.path.exists(default_profile_path(name)): - return "settings" + for path in user_profile_candidates(name, namespace) + default_profile_candidates( + name, namespace + ): + if os.path.exists(path): + return "settings" return "unknown" -def is_profile_read_only(name: str) -> bool: - """Return True when the profile originates from bundled module or plugin directories.""" - return profile_origin(name) in {"module", "plugin"} +def is_profile_read_only(name: str, namespace: str | None = None) -> bool: + """ + Check whether a profile is read-only because it originates from bundles. + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to consider. Defaults to ``None``. -def profile_origin_display(name: str) -> str | None: - """Return a human-readable label for the profile's origin.""" - origin = profile_origin(name) + Returns: + bool: ``True`` if the profile originates from module or plugin bundles. + """ + return profile_origin(name, namespace) in {"module", "plugin"} + + +def profile_origin_display(name: str, namespace: str | None = None) -> str | None: + """ + Build a user-facing label describing a profile's origin. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label to consider. Defaults to ``None``. + + Returns: + str | None: Localized display label such as ``"BEC Widgets"`` or ``"User"``, + or ``None`` when origin cannot be determined. + """ + origin = profile_origin(name, namespace) if origin == "module": return "BEC Widgets" if origin == "plugin": @@ -154,26 +407,39 @@ def profile_origin_display(name: str) -> str | None: return None -def delete_profile_files(name: str) -> bool: +def delete_profile_files(name: str, namespace: str | None = None) -> bool: """ Delete the profile files from the writable settings directories. - Removes both the user and default copies (if they exist) and clears the last profile - metadata when applicable. Returns True when at least one file was removed. + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label scoped to the profile. Defaults + to ``None``. + + Returns: + bool: ``True`` if at least one file was removed. """ - if is_profile_read_only(name): - return False + read_only = is_profile_read_only(name, namespace) removed = False - for path in {user_profile_path(name), default_profile_path(name)}: + # Always allow removing user copies; keep default copies for read-only origins. + for path in set(user_profile_candidates(name, namespace)): try: os.remove(path) removed = True except FileNotFoundError: continue - if removed and get_last_profile() == name: - set_last_profile(None) + if not read_only: + for path in set(default_profile_candidates(name, namespace)): + try: + os.remove(path) + removed = True + except FileNotFoundError: + continue + + if removed and get_last_profile(namespace) == name: + set_last_profile(None, namespace) return removed @@ -191,12 +457,32 @@ def delete_profile_files(name: str) -> bool: } -def list_profiles() -> list[str]: - # Collect profiles from writable settings (default + user) - defaults = { - os.path.splitext(f)[0] for f in os.listdir(default_profiles_dir()) if f.endswith(".ini") - } - users = {os.path.splitext(f)[0] for f in os.listdir(user_profiles_dir()) if f.endswith(".ini")} +def list_profiles(namespace: str | None = None) -> list[str]: + """ + Enumerate all known profile names, syncing bundled defaults when missing locally. + + Args: + namespace (str | None, optional): Namespace label scoped to the profile set. + Defaults to ``None``. + + Returns: + list[str]: Sorted unique profile names. + """ + ns = sanitize_namespace(namespace) + + def _collect_from(directory: str) -> set[str]: + if not os.path.isdir(directory): + return set() + return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")} + + settings_dirs = {default_profiles_dir(namespace), user_profiles_dir(namespace)} + if ns: + settings_dirs.add(default_profiles_dir(None)) + settings_dirs.add(user_profiles_dir(None)) + + settings_names: set[str] = set() + for directory in settings_dirs: + settings_names |= _collect_from(directory) # Also consider read-only defaults from core module and beamline plugin repositories read_only_sources: dict[str, tuple[str, str]] = {} @@ -214,62 +500,127 @@ def list_profiles() -> list[str]: read_only_sources.setdefault(name, (origin, os.path.join(directory, filename))) for name, (_origin, src) in sorted(read_only_sources.items()): - # Ensure a copy in the settings default directory so existing code paths work unchanged - dst_default = default_profile_path(name) + # Ensure a copy in the namespace-specific settings default directory + dst_default = default_profile_path(name, namespace) if not os.path.exists(dst_default): os.makedirs(os.path.dirname(dst_default), exist_ok=True) shutil.copyfile(src, dst_default) # Ensure a user copy exists to allow edits in the writable settings area - dst_user = user_profile_path(name) + dst_user = user_profile_path(name, namespace) if not os.path.exists(dst_user): os.makedirs(os.path.dirname(dst_user), exist_ok=True) shutil.copyfile(src, dst_user) - # Minimal metadata touch-up to align with existing expectations - s = open_user_settings(name) - if not s.value(SETTINGS_KEYS["created_at"], ""): + s = open_user_settings(name, namespace) + if s.value(SETTINGS_KEYS["created_at"], "") == "": s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - defaults |= set(read_only_sources.keys()) - users |= set(read_only_sources.keys()) + settings_names |= set(read_only_sources.keys()) # Return union of all discovered names - return sorted(defaults | users) + return sorted(settings_names) + + +def open_default_settings(name: str, namespace: str | None = None) -> QSettings: + """ + Open (and create if necessary) the default profile settings file. + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. -def open_default_settings(name: str) -> QSettings: - return QSettings(default_profile_path(name), QSettings.IniFormat) + Returns: + QSettings: Settings instance targeting the default profile file. + """ + return QSettings(default_profile_path(name, namespace), QSettings.IniFormat) -def open_user_settings(name: str) -> QSettings: - return QSettings(user_profile_path(name), QSettings.IniFormat) +def open_user_settings(name: str, namespace: str | None = None) -> QSettings: + """ + Open (and create if necessary) the user profile settings file. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QSettings: Settings instance targeting the user profile file. + """ + return QSettings(user_profile_path(name, namespace), QSettings.IniFormat) def _app_settings() -> QSettings: - """Return app-wide settings file for AdvancedDockArea metadata.""" + """ + Access the application-wide metadata settings file for dock profiles. + + Returns: + QSettings: Handle to the ``_meta.ini`` metadata store under the profiles root. + """ return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat) -def get_last_profile() -> str | None: - """Return the last-used profile name if stored, else None.""" +def _last_profile_key(namespace: str | None) -> str: + """ + Build the QSettings key used to store the last profile per namespace. + + Args: + namespace (str | None): Namespace label. + + Returns: + str: Scoped key string. + """ + ns = sanitize_namespace(namespace) + key = SETTINGS_KEYS["last_profile"] + return f"{key}/{ns}" if ns else key + + +def get_last_profile(namespace: str | None = None) -> str | None: + """ + Retrieve the last-used profile name persisted in app settings. + + Args: + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + str | None: Profile name or ``None`` if none has been stored. + """ s = _app_settings() - name = s.value(SETTINGS_KEYS["last_profile"], "", type=str) + name = s.value(_last_profile_key(namespace), "", type=str) return name or None -def set_last_profile(name: str | None) -> None: - """Persist the last-used profile name (or clear it if None).""" +def set_last_profile(name: str | None, namespace: str | None = None) -> None: + """ + Persist the last-used profile name (or clear the value when ``None``). + + Args: + name (str | None): Profile name to store. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + """ s = _app_settings() if name: - s.setValue(SETTINGS_KEYS["last_profile"], name) + s.setValue(_last_profile_key(namespace), name) else: - s.remove(SETTINGS_KEYS["last_profile"]) + s.remove(_last_profile_key(namespace)) def now_iso_utc() -> str: + """ + Return the current UTC timestamp formatted in ISO 8601. + + Returns: + str: UTC timestamp string (e.g., ``"2024-06-05T12:34:56Z"``). + """ return QDateTime.currentDateTimeUtc().toString(Qt.ISODate) def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: + """ + Write the manifest of dock widgets to settings. + + Args: + settings(QSettings): Settings object to write to. + docks(list[CDockWidget]): List of dock widgets to serialize. + """ settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks)) for i, dock in enumerate(docks): settings.setArrayIndex(i) @@ -283,6 +634,15 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: def read_manifest(settings: QSettings) -> list[dict]: + """ + Read the manifest of dock widgets from settings. + + Args: + settings(QSettings): Settings object to read from. + + Returns: + list[dict]: List of dock widget metadata dictionaries. + """ items: list[dict] = [] count = settings.beginReadArray(SETTINGS_KEYS["manifest"]) for i in range(count): @@ -300,47 +660,88 @@ def read_manifest(settings: QSettings) -> list[dict]: return items -def restore_user_from_default(name: str) -> None: - """Overwrite the user profile with the default baseline (keep default intact).""" - src = default_profile_path(name) - dst = user_profile_path(name) - if not os.path.exists(src): +def restore_user_from_default(name: str, namespace: str | None = None) -> None: + """ + Copy the default profile to the user profile, preserving quick-select flag. + + Args: + name(str): Profile name without extension. + namespace(str | None, optional): Namespace label. Defaults to ``None``. + """ + src = None + for candidate in default_profile_candidates(name, namespace): + if os.path.exists(candidate): + src = candidate + break + if not src: return - preserve_quick_select = is_quick_select(name) + dst = user_profile_path(name, namespace) + preserve_quick_select = is_quick_select(name, namespace) os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copyfile(src, dst) - s = open_user_settings(name) + s = open_user_settings(name, namespace) if not s.value(SETTINGS_KEYS["created_at"], ""): s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) if preserve_quick_select: s.setValue(SETTINGS_KEYS["is_quick_select"], True) -def is_quick_select(name: str) -> bool: - """Return True if profile is marked to appear in quick-select combo.""" - s = ( - open_user_settings(name) - if os.path.exists(user_profile_path(name)) - else (open_default_settings(name) if os.path.exists(default_profile_path(name)) else None) - ) +def is_quick_select(name: str, namespace: str | None = None) -> bool: + """ + Return True if profile is marked to appear in quick-select combo. + + Args: + name(str): Profile name without extension. + namespace(str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + bool: True if quick-select is enabled for the profile. + """ + s = _existing_user_settings(name, namespace) + if s is None: + s = _existing_default_settings(name, namespace) if s is None: return False return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool) -def set_quick_select(name: str, enabled: bool) -> None: - """Set/unset the quick-select flag on the USER copy (creates it if missing).""" - s = open_user_settings(name) +def set_quick_select(name: str, enabled: bool, namespace: str | None = None) -> None: + """ + Set or clear the quick-select flag for a profile. + + Args: + name(str): Profile name without extension. + enabled(bool): True to enable quick-select, False to disable. + namespace(str | None, optional): Namespace label. Defaults to ``None``. + """ + s = open_user_settings(name, namespace) s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled)) -def list_quick_profiles() -> list[str]: - """List only profiles that have quick-select enabled (user wins over default).""" - names = list_profiles() - return [n for n in names if is_quick_select(n)] +def list_quick_profiles(namespace: str | None = None) -> list[str]: + """ + List only profiles that have quick-select enabled (user wins over default). + + Args: + namespace(str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + list[str]: Sorted list of profile names with quick-select enabled. + """ + names = list_profiles(namespace) + return [n for n in names if is_quick_select(n, namespace)] def _file_modified_iso(path: str) -> str: + """ + Get the file modification time as an ISO 8601 UTC string. + + Args: + path(str): Path to the file. + + Returns: + str: ISO 8601 UTC timestamp of last modification, or current time if unavailable. + """ try: mtime = os.path.getmtime(path) return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate) @@ -349,12 +750,30 @@ def _file_modified_iso(path: str) -> str: def _manifest_count(settings: QSettings) -> int: + """ + Get the number of widgets recorded in the manifest. + + Args: + settings(QSettings): Settings object to read from. + + Returns: + int: Number of widgets in the manifest. + """ n = settings.beginReadArray(SETTINGS_KEYS["manifest"]) settings.endArray() return int(n or 0) def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None: + """ + Load the screenshot pixmap stored in the given settings. + + Args: + settings(QSettings): Settings object to read from. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ data = settings.value(SETTINGS_KEYS["screenshot"], None) if not data: return None @@ -379,6 +798,8 @@ def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None: class ProfileInfo(BaseModel): + """Pydantic model capturing profile metadata surfaced in the UI.""" + name: str author: str = "BEC Widgets" notes: str = "" @@ -393,21 +814,30 @@ class ProfileInfo(BaseModel): is_read_only: bool = False -def get_profile_info(name: str) -> ProfileInfo: +def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo: """ - Return merged metadata for a profile as a validated Pydantic model. - Prefers the USER copy; falls back to DEFAULT if the user copy is missing. + Assemble metadata and statistics for a profile. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + ProfileInfo: Structured profile metadata, preferring the user copy when present. """ - u_path = user_profile_path(name) - d_path = default_profile_path(name) - origin = profile_origin(name) - prefer_user = os.path.exists(u_path) + user_paths = user_profile_candidates(name, namespace) + default_paths = default_profile_candidates(name, namespace) + u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0]) + d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0]) + origin = profile_origin(name, namespace) read_only = origin in {"module", "plugin"} - s = ( - open_user_settings(name) - if prefer_user - else (open_default_settings(name) if os.path.exists(d_path) else None) - ) + prefer_user = os.path.exists(u_path) + if prefer_user: + s = QSettings(u_path, QSettings.IniFormat) + elif os.path.exists(d_path): + s = QSettings(d_path, QSettings.IniFormat) + else: + s = None if s is None: if origin == "module": author = "BEC Widgets" @@ -456,7 +886,7 @@ def get_profile_info(name: str) -> ProfileInfo: notes=s.value("profile/notes", "", type=str) or "", created=created, modified=modified, - is_quick_select=is_quick_select(name), + is_quick_select=is_quick_select(name, namespace), widget_count=count, size_kb=size_kb, user_path=u_path, @@ -466,29 +896,54 @@ def get_profile_info(name: str) -> ProfileInfo: ) -def load_profile_screenshot(name: str) -> QPixmap | None: - """Load the stored screenshot pixmap for a profile from settings (user preferred).""" - u_path = user_profile_path(name) - d_path = default_profile_path(name) - s = ( - open_user_settings(name) - if os.path.exists(u_path) - else (open_default_settings(name) if os.path.exists(d_path) else None) - ) +def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: + """ + Load the stored screenshot pixmap for a profile from settings (user preferred). + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + s = _existing_user_settings(name, namespace) + if s is None: + s = _existing_default_settings(name, namespace) if s is None: return None return _load_screenshot_from_settings(s) -def load_user_profile_screenshot(name: str) -> QPixmap | None: - """Load the screenshot from the user profile copy, if available.""" - if not os.path.exists(user_profile_path(name)): +def load_default_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: + """ + Load the screenshot from the default profile copy, if available. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + s = _existing_default_settings(name, namespace) + if s is None: return None - return _load_screenshot_from_settings(open_user_settings(name)) + return _load_screenshot_from_settings(s) -def load_default_profile_screenshot(name: str) -> QPixmap | None: - """Load the screenshot from the default profile copy, if available.""" - if not os.path.exists(default_profile_path(name)): +def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None: + """ + Load the screenshot from the user profile copy, if available. + + Args: + name (str): Profile name without extension. + namespace (str | None, optional): Namespace label. Defaults to ``None``. + + Returns: + QPixmap | None: Screenshot pixmap or ``None`` if unavailable. + """ + s = _existing_user_settings(name, namespace) + if s is None: return None - return _load_screenshot_from_settings(open_default_settings(name)) + return _load_screenshot_from_settings(s) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py index e9d60f3f0..19329c34b 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py @@ -5,7 +5,6 @@ from qtpy.QtCore import Qt from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( - QApplication, QCheckBox, QDialog, QGroupBox, @@ -183,10 +182,10 @@ def accept(self): "Overwriting will update both the saved profile and its restore default.\n" "Do you want to continue?" ), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if reply != QMessageBox.Yes: + if reply != QMessageBox.StandardButton.Yes: suggestion = self._generate_unique_name(name) self._block_name_signals = True self.name_edit.setText(suggestion) @@ -206,16 +205,15 @@ class PreviewPanel(QGroupBox): def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = None): super().__init__(title, parent) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._original: QPixmap | None = pixmap if (pixmap and not pixmap.isNull()) else None layout = QVBoxLayout(self) - # layout.setContentsMargins(0,0,0,0) # leave room for group title and frame self.image_label = QLabel() - self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.image_label.setMinimumSize(360, 240) - self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.image_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) layout.addWidget(self.image_label, 1) if self._original: @@ -227,6 +225,13 @@ def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = ) def setPixmap(self, pixmap: QPixmap | None): + """ + Set the pixmap to display in the preview panel. + + Args: + pixmap(QPixmap | None): The pixmap to display. If None or null, clears the preview. + + """ self._original = pixmap if (pixmap and not pixmap.isNull()) else None if self._original: self.image_label.setText("") diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py index 936227479..36357a17e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -2,6 +2,7 @@ from functools import partial +from bec_lib import bec_logger from bec_qthemes import material_icon from qtpy.QtCore import Qt from qtpy.QtGui import QPixmap @@ -11,8 +12,6 @@ QHBoxLayout, QHeaderView, QLabel, - QLineEdit, - QMainWindow, QMessageBox, QPushButton, QSizePolicy, @@ -28,7 +27,7 @@ ) from bec_widgets import BECWidget, SafeSlot -from bec_widgets.utils.colors import apply_theme, get_accent_colors +from bec_widgets.utils.colors import get_accent_colors from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( delete_profile_files, get_profile_info, @@ -38,6 +37,8 @@ set_quick_select, ) +logger = bec_logger.logger + class WorkSpaceManager(BECWidget, QWidget): RPC = False @@ -52,6 +53,9 @@ def __init__( ): super().__init__(parent=parent, **kwargs) self.target_widget = target_widget + self.profile_namespace = ( + getattr(target_widget, "profile_namespace", None) if target_widget else None + ) self.accent_colors = get_accent_colors() self._init_ui() if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"): @@ -144,7 +148,7 @@ def _init_screenshot_preview(self): def render_table(self): self.profile_table.setRowCount(0) - for profile in list_profiles(): + for profile in list_profiles(namespace=self.profile_namespace): self._add_profile_row(profile) def _add_profile_row(self, name: str): @@ -156,7 +160,7 @@ def _add_profile_row(self, name: str): actions_items_layout = QHBoxLayout(actions_items) actions_items_layout.setContentsMargins(0, 0, 0, 0) - info = get_profile_info(name) + info = get_profile_info(name, namespace=self.profile_namespace) # Flags is_active = ( @@ -237,7 +241,7 @@ def _current_selected_profile(self) -> str | None: return item.text() if item else None def _show_profile_details(self, name: str) -> None: - info = get_profile_info(name) + info = get_profile_info(name, namespace=self.profile_namespace) self.profile_details_tree.clear() entries = [ ("Name", info.name), @@ -255,7 +259,7 @@ def _show_profile_details(self, name: str) -> None: self.profile_details_tree.expandAll() # Render screenshot preview from profile INI - pm = load_profile_screenshot(name) + pm = load_profile_screenshot(name, namespace=self.profile_namespace) if pm is not None and not pm.isNull(): scaled = pm.scaled( self.screenshot_label.width() or 800, @@ -299,16 +303,16 @@ def switch_profile(self, profile_name: str): "workspace_combo" ).widget.setCurrentText(profile_name) except Exception as e: - print(f"Warning: Could not update workspace combo box. {e}") - pass + logger.warning(f"Warning: Could not update workspace combo box. {e}") + self.render_table() self._select_by_name(profile_name) self._show_profile_details(profile_name) @SafeSlot(str) def toggle_quick_select(self, profile_name: str): - enabled = is_quick_select(profile_name) - set_quick_select(profile_name, not enabled) + enabled = is_quick_select(profile_name, namespace=self.profile_namespace) + set_quick_select(profile_name, not enabled, namespace=self.profile_namespace) self.render_table() if self.target_widget is not None: self.target_widget._refresh_workspace_list() @@ -337,7 +341,7 @@ def save_current_as_profile(self): @SafeSlot(str) def delete_profile(self, profile_name: str): - info = get_profile_info(profile_name) + info = get_profile_info(profile_name, namespace=self.profile_namespace) if info.is_read_only: QMessageBox.information( self, "Delete Profile", "This profile is read-only and cannot be deleted." @@ -358,7 +362,7 @@ def delete_profile(self, profile_name: str): return try: - removed = delete_profile_files(profile_name) + removed = delete_profile_files(profile_name, namespace=self.profile_namespace) except OSError as exc: QMessageBox.warning( self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}" @@ -378,7 +382,7 @@ def delete_profile(self, profile_name: str): self.target_widget._refresh_workspace_list() self.render_table() - remaining_profiles = list_profiles() + remaining_profiles = list_profiles(namespace=self.profile_namespace) if remaining_profiles: next_profile = remaining_profiles[0] self._select_by_name(next_profile) @@ -392,7 +396,7 @@ def resizeEvent(self, event): name = self._current_selected_profile() if not name: return - pm = load_profile_screenshot(name) + pm = load_profile_screenshot(name, namespace=self.profile_namespace) if pm is None or pm.isNull(): return scaled = pm.scaled( 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 bdfb9a5a2..cb7dabea1 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 @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Callable + from qtpy.QtCore import Qt from qtpy.QtGui import QFont from qtpy.QtWidgets import QComboBox, QSizePolicy @@ -17,6 +19,10 @@ class ProfileComboBox(QComboBox): def __init__(self, parent=None): super().__init__(parent) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self._quick_provider: Callable[[], list[str]] = list_quick_profiles + + def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None: + self._quick_provider = provider def refresh_profiles(self, active_profile: str | None = None): """ @@ -30,7 +36,7 @@ def refresh_profiles(self, active_profile: str | None = None): self.blockSignals(True) self.clear() - quick_profiles = list_quick_profiles() + quick_profiles = self._quick_provider() quick_set = set(quick_profiles) items = list(quick_profiles) @@ -76,7 +82,7 @@ def refresh_profiles(self, active_profile: str | None = None): self.setToolTip("") -def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: +def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle: """ Creates a workspace toolbar bundle for AdvancedDockArea. @@ -88,9 +94,9 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: """ # Workspace combo combo = ProfileComboBox(parent=components.toolbar) + combo.setVisible(enable_tools) components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False)) - # Save the current workspace icon components.add_safe( "save_workspace", MaterialIconAction( @@ -100,7 +106,8 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: parent=components.toolbar, ), ) - # Delete workspace icon + components.get_action("save_workspace").action.setVisible(enable_tools) + components.add_safe( "reset_default_workspace", MaterialIconAction( @@ -110,17 +117,15 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle: parent=components.toolbar, ), ) - # Workspace Manager icon + components.get_action("reset_default_workspace").action.setVisible(enable_tools) + components.add_safe( "manage_workspaces", MaterialIconAction( - icon_name="manage_accounts", - tooltip="Manage", - checkable=True, - parent=components.toolbar, - label_text="Manage", + icon_name="manage_accounts", tooltip="Manage", checkable=True, parent=components.toolbar ), ) + components.get_action("manage_workspaces").action.setVisible(enable_tools) bundle = ToolbarBundle("workspace", components) bundle.add_action("workspace_combo") @@ -147,35 +152,40 @@ def __init__(self, components: ToolbarComponents, target_widget=None): def connect(self): self._connected = True # Connect the action to the target widget's method - self.components.get_action("save_workspace").action.triggered.connect( - self.target_widget.save_profile - ) + save_action = self.components.get_action("save_workspace").action + if save_action.isVisible(): + save_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("reset_default_workspace").action.triggered.connect( - self._reset_workspace_to_default - ) - self.components.get_action("manage_workspaces").action.triggered.connect( - self.target_widget.show_workspace_manager - ) + + reset_action = self.components.get_action("reset_default_workspace").action + if reset_action.isVisible(): + reset_action.triggered.connect(self._reset_workspace_to_default) + + manage_action = self.components.get_action("manage_workspaces").action + if manage_action.isVisible(): + manage_action.triggered.connect(self.target_widget.show_workspace_manager) def disconnect(self): if not self._connected: return # Disconnect the action from the target widget's method - self.components.get_action("save_workspace").action.triggered.disconnect( - self.target_widget.save_profile - ) + save_action = self.components.get_action("save_workspace").action + if save_action.isVisible(): + save_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("reset_default_workspace").action.triggered.disconnect( - self._reset_workspace_to_default - ) - self.components.get_action("manage_workspaces").action.triggered.disconnect( - self.target_widget.show_workspace_manager - ) + + reset_action = self.components.get_action("reset_default_workspace").action + if reset_action.isVisible(): + reset_action.triggered.disconnect(self._reset_workspace_to_default) + + manage_action = self.components.get_action("manage_workspaces").action + if manage_action.isVisible(): + manage_action.triggered.disconnect(self.target_widget.show_workspace_manager) self._connected = False @SafeSlot() diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 27b43dfcf..019874ea2 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -2,7 +2,6 @@ import os -import shiboken6 from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer from qtpy.QtGui import QAction, QActionGroup, QIcon @@ -22,7 +21,6 @@ from bec_widgets.utils.bec_widget import BECWidget 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 from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import ( BECNotificationBroker, diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 6dfe76e59..212494b2f 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -6,16 +6,20 @@ from unittest.mock import MagicMock, patch import pytest -from qtpy.QtCore import QSettings, Qt +from qtpy.QtCore import QSettings, Qt, QTimer from qtpy.QtGui import QPixmap -from qtpy.QtWidgets import QDialog, QMessageBox +from qtpy.QtWidgets import QDialog, QMessageBox, QWidget +import bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area as basic_dock_module import bec_widgets.widgets.containers.advanced_dock_area.profile_utils as profile_utils from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( AdvancedDockArea, - DockSettingsDialog, SaveProfileDialog, ) +from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import ( + DockAreaWidget, + DockSettingsDialog, +) from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( default_profile_path, get_profile_info, @@ -145,13 +149,372 @@ def _factory(): return _factory +@pytest.fixture +def basic_dock_area(qtbot, mocked_client): + """Create a namesake DockAreaWidget without the advanced toolbar.""" + widget = DockAreaWidget(client=mocked_client, title="Test Dock Area") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +class _NamespaceProfiles: + """Helper that routes profile file helpers through a namespace.""" + + def __init__(self, widget: AdvancedDockArea): + self.namespace = widget.profile_namespace + + def open_user(self, name: str): + return open_user_settings(name, namespace=self.namespace) + + def open_default(self, name: str): + return open_default_settings(name, namespace=self.namespace) + + def user_path(self, name: str) -> str: + return user_profile_path(name, namespace=self.namespace) + + def default_path(self, name: str) -> str: + return default_profile_path(name, namespace=self.namespace) + + def list_profiles(self) -> list[str]: + return list_profiles(namespace=self.namespace) + + def set_quick_select(self, name: str, enabled: bool): + set_quick_select(name, enabled, namespace=self.namespace) + + def is_quick_select(self, name: str) -> bool: + return is_quick_select(name, namespace=self.namespace) + + +def profile_helper(widget: AdvancedDockArea) -> _NamespaceProfiles: + """Return a helper wired to the widget's profile namespace.""" + return _NamespaceProfiles(widget) + + +class TestBasicDockArea: + """Focused coverage for the lightweight DockAreaWidget base.""" + + def test_new_widget_instance_registers_in_maps(self, basic_dock_area): + panel = QWidget(parent=basic_dock_area) + panel.setObjectName("basic_panel") + + dock = basic_dock_area.new(panel, return_dock=True) + + assert dock.objectName() == "basic_panel" + assert basic_dock_area.dock_map()["basic_panel"] is dock + assert basic_dock_area.widget_map()["basic_panel"] is panel + + def test_new_widget_string_creates_widget(self, basic_dock_area, qtbot): + basic_dock_area.new("DarkModeButton") + qtbot.waitUntil(lambda: len(basic_dock_area.dock_list()) > 0, timeout=1000) + + assert basic_dock_area.widget_list() + + def test_custom_close_handler_invoked(self, basic_dock_area, qtbot): + class CloseAwareWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("closable") + self.closed = False + + def handle_dock_close(self, dock, widget): # pragma: no cover - exercised via signal + self.closed = True + dock.closeDockWidget() + dock.deleteDockWidget() + + widget = CloseAwareWidget(parent=basic_dock_area) + dock = basic_dock_area.new(widget, return_dock=True) + + dock.closeRequested.emit() + qtbot.waitUntil(lambda: widget.closed, timeout=1000) + + assert widget.closed is True + assert "closable" not in basic_dock_area.dock_map() + + def test_attach_all_and_delete_all(self, basic_dock_area): + first = QWidget(parent=basic_dock_area) + first.setObjectName("floating_one") + second = QWidget(parent=basic_dock_area) + second.setObjectName("floating_two") + + dock_one = basic_dock_area.new(first, return_dock=True, start_floating=True) + dock_two = basic_dock_area.new(second, return_dock=True, start_floating=True) + assert dock_one.isFloating() and dock_two.isFloating() + + basic_dock_area.attach_all() + + assert not dock_one.isFloating() + assert not dock_two.isFloating() + + basic_dock_area.delete_all() + assert basic_dock_area.dock_list() == [] + + def test_splitter_weight_coercion_supports_aliases(self, basic_dock_area): + weights = {"default": 0.5, "left": 2, "center": 3, "right": 4} + + result = basic_dock_area._coerce_weights(weights, 3, Qt.Orientation.Horizontal) + + assert result == [2.0, 3.0, 4.0] + assert basic_dock_area._coerce_weights([0.0], 3, Qt.Orientation.Vertical) == [0.0, 1.0, 1.0] + assert basic_dock_area._coerce_weights([0.0, 0.0], 2, Qt.Orientation.Vertical) == [1.0, 1.0] + + def test_splitter_override_keys_are_normalized(self, basic_dock_area): + overrides = {0: [1, 2], (1, 0): [3, 4], "2.1": [5], " / ": [6]} + + normalized = basic_dock_area._normalize_override_keys(overrides) + + assert normalized == {(0,): [1, 2], (1, 0): [3, 4], (2, 1): [5], (): [6]} + + def test_schedule_splitter_weights_sets_sizes(self, basic_dock_area, monkeypatch): + monkeypatch.setattr(QTimer, "singleShot", lambda *_args: _args[-1]()) + + class DummySplitter: + def __init__(self): + self._children = [object(), object(), object()] + self.sizes = None + self.stretch = [] + + def count(self): + return len(self._children) + + def orientation(self): + return Qt.Orientation.Horizontal + + def width(self): + return 300 + + def height(self): + return 120 + + def setSizes(self, sizes): + self.sizes = sizes + + def setStretchFactor(self, idx, value): + self.stretch.append((idx, value)) + + splitter = DummySplitter() + + basic_dock_area._schedule_splitter_weights(splitter, [1, 2, 1]) + + assert splitter.sizes == [75, 150, 75] + assert splitter.stretch == [(0, 100), (1, 200), (2, 100)] + + def test_apply_splitter_tree_honors_overrides(self, basic_dock_area, monkeypatch): + class DummySplitter: + def __init__(self, orientation, children=None, label="splitter"): + self._orientation = orientation + self._children = list(children or []) + self.label = label + + def count(self): + return len(self._children) + + def orientation(self): + return self._orientation + + def widget(self, idx): + return self._children[idx] + + monkeypatch.setattr(basic_dock_module.QtAds, "CDockSplitter", DummySplitter) + + leaf = DummySplitter(Qt.Orientation.Horizontal, [], label="leaf") + column_one = DummySplitter(Qt.Orientation.Vertical, [leaf], label="column_one") + column_zero = DummySplitter(Qt.Orientation.Vertical, [], label="column_zero") + root = DummySplitter(Qt.Orientation.Horizontal, [column_zero, column_one], label="root") + + calls = [] + + def fake_schedule(self, splitter, weights): + calls.append((splitter.label, weights)) + + monkeypatch.setattr(DockAreaWidget, "_schedule_splitter_weights", fake_schedule) + + overrides = {(): ["root_override"], (0,): ["column_override"]} + + basic_dock_area._apply_splitter_tree( + root, (), horizontal=[1, 2], vertical=[3, 4], overrides=overrides + ) + + assert calls[0] == ("root", ["root_override"]) + assert calls[1] == ("column_zero", ["column_override"]) + assert calls[2] == ("column_one", [3, 4]) + assert calls[3] == ("leaf", ["column_override"]) + + def test_set_layout_ratios_normalizes_and_applies(self, basic_dock_area, monkeypatch): + class DummyContainer: + def __init__(self, splitter): + self._splitter = splitter + + def rootSplitter(self): + return self._splitter + + root_one = object() + root_two = object() + containers = [DummyContainer(root_one), DummyContainer(None), DummyContainer(root_two)] + + monkeypatch.setattr(basic_dock_area.dock_manager, "dockContainers", lambda: containers) + + calls = [] + + def fake_apply(self, splitter, path, horizontal, vertical, overrides): + calls.append((splitter, path, horizontal, vertical, overrides)) + + monkeypatch.setattr(DockAreaWidget, "_apply_splitter_tree", fake_apply) + + basic_dock_area.set_layout_ratios( + horizontal=[1, 1, 1], vertical=[2, 3], splitter_overrides={"1/0": [5, 5], "": [9]} + ) + + assert len(calls) == 2 + for splitter, path, horizontal, vertical, overrides in calls: + assert splitter in {root_one, root_two} + assert path == () + assert horizontal == [1, 1, 1] + assert vertical == [2, 3] + assert overrides == {(): [9], (1, 0): [5, 5]} + + def test_show_settings_action_defaults_disabled(self, basic_dock_area): + widget = QWidget(parent=basic_dock_area) + widget.setObjectName("settings_default") + + dock = basic_dock_area.new(widget, return_dock=True) + + assert dock._dock_preferences.get("show_settings_action") is False + assert not hasattr(dock, "setting_action") + + def test_show_settings_action_can_be_enabled(self, basic_dock_area): + widget = QWidget(parent=basic_dock_area) + widget.setObjectName("settings_enabled") + + dock = basic_dock_area.new(widget, return_dock=True, show_settings_action=True) + + assert dock._dock_preferences.get("show_settings_action") is True + assert hasattr(dock, "setting_action") + assert dock.setting_action.toolTip() == "Dock settings" + + def test_collect_splitter_info_describes_children(self, basic_dock_area, monkeypatch): + class DummyDockWidget: + def __init__(self, name): + self._name = name + + def objectName(self): + return self._name + + class DummyDockArea: + def __init__(self, dock_names): + self._docks = [DummyDockWidget(name) for name in dock_names] + + def dockWidgets(self): + return self._docks + + class DummySplitter: + def __init__(self, orientation, children=None): + self._orientation = orientation + self._children = list(children or []) + + def orientation(self): + return self._orientation + + def count(self): + return len(self._children) + + def widget(self, idx): + return self._children[idx] + + class Spacer: + pass + + monkeypatch.setattr(basic_dock_module, "CDockSplitter", DummySplitter) + monkeypatch.setattr(basic_dock_module, "CDockAreaWidget", DummyDockArea) + monkeypatch.setattr(basic_dock_module, "CDockWidget", DummyDockWidget) + + nested_splitter = DummySplitter(Qt.Orientation.Horizontal) + dock_area_child = DummyDockArea(["left", "right"]) + dock_child = DummyDockWidget("solo") + spacer = Spacer() + root_splitter = DummySplitter( + Qt.Orientation.Vertical, [nested_splitter, dock_area_child, dock_child, spacer] + ) + + results = [] + + basic_dock_area._collect_splitter_info(root_splitter, (2,), results, container_index=5) + + assert len(results) == 2 + root_entry = results[0] + assert root_entry["container"] == 5 + assert root_entry["path"] == (2,) + assert root_entry["orientation"] == "vertical" + assert root_entry["children"] == [ + {"index": 0, "type": "splitter"}, + {"index": 1, "type": "dock_area", "docks": ["left", "right"]}, + {"index": 2, "type": "dock", "name": "solo"}, + {"index": 3, "type": "Spacer"}, + ] + nested_entry = results[1] + assert nested_entry["path"] == (2, 0) + assert nested_entry["orientation"] == "horizontal" + + def test_describe_layout_aggregates_containers(self, basic_dock_area, monkeypatch): + class DummyContainer: + def __init__(self, splitter): + self._splitter = splitter + + def rootSplitter(self): + return self._splitter + + containers = [DummyContainer("root0"), DummyContainer(None), DummyContainer("root2")] + monkeypatch.setattr(basic_dock_area.dock_manager, "dockContainers", lambda: containers) + + calls = [] + + def recorder(self, splitter, path, results, container_index): + entry = {"container": container_index, "splitter": splitter, "path": path} + results.append(entry) + calls.append(entry) + + monkeypatch.setattr(DockAreaWidget, "_collect_splitter_info", recorder) + + info = basic_dock_area.describe_layout() + + assert info == calls + assert [entry["splitter"] for entry in info] == ["root0", "root2"] + assert [entry["container"] for entry in info] == [0, 2] + assert all(entry["path"] == () for entry in info) + + def test_print_layout_structure_formats_output(self, basic_dock_area, monkeypatch, capsys): + entries = [ + { + "container": 1, + "path": (0,), + "orientation": "horizontal", + "children": [ + {"index": 0, "type": "dock_area", "docks": ["alpha", "beta"]}, + {"index": 1, "type": "dock", "name": "solo"}, + {"index": 2, "type": "splitter"}, + {"index": 3, "type": "Placeholder"}, + ], + } + ] + + monkeypatch.setattr(DockAreaWidget, "describe_layout", lambda self: entries) + + basic_dock_area.print_layout_structure() + + captured = capsys.readouterr().out.strip().splitlines() + assert captured == [ + "container=1 path=(0,) orientation=horizontal -> " + "[0:dock_area[alpha, beta], 1:dock(solo), 2:splitter, 3:Placeholder]" + ] + + 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 advanced_dock_area.mode == "developer" + assert advanced_dock_area.mode == "creator" assert hasattr(advanced_dock_area, "dock_manager") assert hasattr(advanced_dock_area, "toolbar") assert hasattr(advanced_dock_area, "dark_mode_button") @@ -293,6 +656,29 @@ def test_delete_all(self, advanced_dock_area, qtbot): assert len(advanced_dock_area.dock_list()) == 0 +class TestAdvancedDockSettingsAction: + """Ensure AdvancedDockArea exposes dock settings actions by default.""" + + def test_settings_action_installed_by_default(self, advanced_dock_area): + widget = QWidget(parent=advanced_dock_area) + widget.setObjectName("advanced_default_settings") + + dock = advanced_dock_area.new(widget, return_dock=True) + + assert hasattr(dock, "setting_action") + assert dock.setting_action.toolTip() == "Dock settings" + assert dock._dock_preferences.get("show_settings_action") is True + + def test_settings_action_can_be_disabled(self, advanced_dock_area): + widget = QWidget(parent=advanced_dock_area) + widget.setObjectName("advanced_settings_off") + + dock = advanced_dock_area.new(widget, return_dock=True, show_settings_action=False) + + assert not hasattr(dock, "setting_action") + assert dock._dock_preferences.get("show_settings_action") is False + + class TestWorkspaceLocking: """Test workspace locking functionality.""" @@ -873,6 +1259,11 @@ def test_delete_readonly_profile_shows_message( ): readonly = module_profile_factory("readonly_delete") list_profiles() + monkeypatch.setattr( + profile_utils, + "get_profile_info", + lambda *a, **k: profile_utils.ProfileInfo(name=readonly, is_read_only=True), + ) info_calls = [] monkeypatch.setattr( QMessageBox, @@ -892,19 +1283,20 @@ class TestAdvancedDockAreaRestoreAndDialogs: def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area, monkeypatch): profile_name = "profile_restore_true" - open_default_settings(profile_name).sync() - open_user_settings(profile_name).sync() + helper = profile_helper(advanced_dock_area) + helper.open_default(profile_name).sync() + helper.open_user(profile_name).sync() advanced_dock_area._current_profile_name = profile_name advanced_dock_area.isVisible = lambda: False pix = QPixmap(8, 8) pix.fill(Qt.red) monkeypatch.setattr( "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot", - lambda name: pix, + lambda name, namespace=None: pix, ) monkeypatch.setattr( "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", - lambda name: pix, + lambda name, namespace=None: pix, ) monkeypatch.setattr( "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm", @@ -920,14 +1312,18 @@ def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area ): advanced_dock_area.restore_user_profile_from_default() - mock_restore.assert_called_once_with(profile_name) + assert mock_restore.call_count == 1 + args, kwargs = mock_restore.call_args + assert args == (profile_name,) + assert kwargs.get("namespace") == advanced_dock_area.profile_namespace mock_delete_all.assert_called_once() mock_load_profile.assert_called_once_with(profile_name) def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_area, monkeypatch): profile_name = "profile_restore_false" - open_default_settings(profile_name).sync() - open_user_settings(profile_name).sync() + helper = profile_helper(advanced_dock_area) + helper.open_default(profile_name).sync() + helper.open_user(profile_name).sync() advanced_dock_area._current_profile_name = profile_name advanced_dock_area.isVisible = lambda: False monkeypatch.setattr( @@ -960,7 +1356,8 @@ def test_restore_user_profile_from_default_no_target(self, advanced_dock_area, m def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area): profile_name = "refresh_profile" - open_user_settings(profile_name).sync() + helper = profile_helper(advanced_dock_area) + helper.open_user(profile_name).sync() advanced_dock_area._current_profile_name = profile_name combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles = MagicMock() @@ -1002,9 +1399,10 @@ def setToolTip(self, text): active = "active_profile" quick = "quick_profile" - open_user_settings(active).sync() - open_user_settings(quick).sync() - set_quick_select(quick, True) + helper = profile_helper(advanced_dock_area) + helper.open_user(active).sync() + helper.open_user(quick).sync() + helper.set_quick_select(quick, True) combo_stub = ComboStub() @@ -1027,7 +1425,8 @@ def test_show_workspace_manager_creates_dialog(self, qtbot, advanced_dock_area): assert not action.isChecked() advanced_dock_area._current_profile_name = "manager_profile" - open_user_settings("manager_profile").sync() + helper = profile_helper(advanced_dock_area) + helper.open_user("manager_profile").sync() advanced_dock_area.show_workspace_manager() @@ -1175,7 +1574,8 @@ def test_save_profile_readonly_conflict( """Test saving profile when read-only profile exists.""" profile_name = module_profile_factory("readonly_profile") new_profile = f"{profile_name}_custom" - target_path = user_profile_path(new_profile) + helper = profile_helper(advanced_dock_area) + target_path = helper.user_path(new_profile) if os.path.exists(target_path): os.remove(target_path) @@ -1203,9 +1603,10 @@ def is_quick_select(self): 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" + helper = profile_helper(advanced_dock_area) # Create a profile with manifest - settings = open_user_settings(profile_name) + settings = helper.open_user(profile_name) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "test_widget") @@ -1232,8 +1633,9 @@ def test_save_as_skips_autosave_source_profile( """Saving a new profile avoids overwriting the source profile during the switch.""" source_profile = "autosave_source" new_profile = "autosave_new" + helper = profile_helper(advanced_dock_area) - settings = open_user_settings(source_profile) + settings = helper.open_user(source_profile) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", "source_widget") @@ -1269,8 +1671,8 @@ def is_quick_select(self): advanced_dock_area.save_profile() qtbot.wait(500) - source_manifest = read_manifest(open_user_settings(source_profile)) - new_manifest = read_manifest(open_user_settings(new_profile)) + source_manifest = read_manifest(helper.open_user(source_profile)) + new_manifest = read_manifest(helper.open_user(new_profile)) assert len(source_manifest) == 1 assert len(new_manifest) == 2 @@ -1279,9 +1681,10 @@ def test_switch_autosaves_previous_profile(self, advanced_dock_area, temp_profil """Regular profile switches should persist the outgoing layout.""" profile_a = "autosave_keep" profile_b = "autosave_target" + helper = profile_helper(advanced_dock_area) for profile in (profile_a, profile_b): - settings = open_user_settings(profile) + settings = helper.open_user(profile) settings.beginWriteArray("manifest/widgets", 1) settings.setArrayIndex(0) settings.setValue("object_name", f"{profile}_widget") @@ -1300,7 +1703,7 @@ def test_switch_autosaves_previous_profile(self, advanced_dock_area, temp_profil advanced_dock_area.load_profile(profile_b) qtbot.wait(500) - manifest_a = read_manifest(open_user_settings(profile_a)) + manifest_a = read_manifest(helper.open_user(profile_a)) assert len(manifest_a) == 2 def test_delete_profile_readonly( @@ -1308,12 +1711,14 @@ def test_delete_profile_readonly( ): """Test deleting bundled profile removes only the writable copy.""" profile_name = module_profile_factory("readonly_profile") - list_profiles() # ensure default and user copies are materialized - settings = open_user_settings(profile_name) + helper = profile_helper(advanced_dock_area) + helper.list_profiles() # ensure default and user copies are materialized + helper.open_default(profile_name).sync() + settings = helper.open_user(profile_name) settings.setValue("test", "value") settings.sync() - user_path = user_profile_path(profile_name) - default_path = default_profile_path(profile_name) + user_path = helper.user_path(profile_name) + default_path = helper.default_path(profile_name) assert os.path.exists(user_path) assert os.path.exists(default_path) @@ -1322,27 +1727,34 @@ def test_delete_profile_readonly( 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( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question", + return_value=QMessageBox.Yes, + ) as mock_question, + patch( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.information", + return_value=None, + ) as mock_info, + ): advanced_dock_area.delete_profile() - mock_question.assert_called_once() - # User copy should be removed, default remains - assert not os.path.exists(user_path) + mock_question.assert_not_called() + mock_info.assert_called_once() + # Read-only profile should remain intact (user + default copies) + assert os.path.exists(user_path) assert os.path.exists(default_path) def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): """Test successful profile deletion.""" profile_name = "deletable_profile" + helper = profile_helper(advanced_dock_area) # Create regular profile - settings = open_user_settings(profile_name) + settings = helper.open_user(profile_name) settings.setValue("test", "value") settings.sync() - user_path = user_profile_path(profile_name) + user_path = helper.user_path(profile_name) assert os.path.exists(user_path) with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action: @@ -1366,8 +1778,9 @@ def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir): def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): """Test refreshing workspace list.""" # Create some profiles + helper = profile_helper(advanced_dock_area) for name in ["profile1", "profile2"]: - settings = open_user_settings(name) + settings = helper.open_user(name) settings.setValue("test", "value") settings.sync() @@ -1451,15 +1864,18 @@ def test_install_dock_settings_action(self, advanced_dock_area): widget = DarkModeButton(parent=advanced_dock_area) widget.setObjectName("test_widget") - dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True) + with patch.object(advanced_dock_area, "_open_dock_settings_dialog") as mock_open_dialog: + 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 dock has settings action + assert hasattr(dock, "setting_action") + assert dock.setting_action is not None + assert dock.setting_action.toolTip() == "Dock settings" - # Verify title bar actions were set - title_bar_actions = dock.titleBarActions() - assert len(title_bar_actions) >= 1 + dock.setting_action.trigger() + mock_open_dialog.assert_called_once_with(dock, widget) class TestModeSwitching: @@ -1467,7 +1883,7 @@ class TestModeSwitching: def test_mode_property_setter_valid_modes(self, advanced_dock_area): """Test setting valid modes.""" - valid_modes = ["plot", "device", "utils", "developer", "user"] + valid_modes = ["plot", "device", "utils", "creator", "user"] for mode in valid_modes: advanced_dock_area.mode = mode @@ -1534,7 +1950,7 @@ def test_utils_mode_toolbar_visibility(self, advanced_dock_area): def test_developer_mode_toolbar_visibility(self, advanced_dock_area): """Test toolbar bundle visibility in developer mode.""" - advanced_dock_area.mode = "developer" + advanced_dock_area.mode = "creator" shown_bundles = advanced_dock_area.toolbar.shown_bundles @@ -1622,7 +2038,7 @@ def test_flat_plot_actions_trigger_widget_creation(self, advanced_dock_area): 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) + mock_new.assert_called_once_with(widget_type) def test_flat_device_actions_trigger_widget_creation(self, advanced_dock_area): """Test flat device actions trigger widget creation.""" @@ -1635,7 +2051,7 @@ def test_flat_device_actions_trigger_widget_creation(self, advanced_dock_area): 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) + mock_new.assert_called_once_with(widget_type) def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area): """Test flat utils actions trigger widget creation.""" @@ -1658,7 +2074,7 @@ def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area): continue action.trigger() - mock_new.assert_called_once_with(widget=widget_type) + mock_new.assert_called_once_with(widget_type) def test_flat_log_panel_action_disabled(self, advanced_dock_area): """Test that flat log panel action is disabled.""" @@ -1671,7 +2087,7 @@ class TestModeTransitions: def test_mode_transition_sequence(self, advanced_dock_area, qtbot): """Test sequence of mode transitions.""" - modes = ["plot", "device", "utils", "developer", "user"] + modes = ["plot", "device", "utils", "creator", "user"] for mode in modes: with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: @@ -1686,7 +2102,7 @@ def test_mode_consistency_after_multiple_changes(self, advanced_dock_area): advanced_dock_area.mode = "plot" advanced_dock_area.mode = "device" advanced_dock_area.mode = "utils" - advanced_dock_area.mode = "developer" + advanced_dock_area.mode = "creator" advanced_dock_area.mode = "user" # Final state should be consistent @@ -1758,7 +2174,7 @@ def test_mode_property_setter_triggers_toolbar_update(self, advanced_dock_area): def test_multiple_mode_changes(self, advanced_dock_area, qtbot): """Test multiple rapid mode changes.""" - modes = ["plot", "device", "utils", "developer", "user"] + modes = ["plot", "device", "utils", "creator", "user"] for i, mode in enumerate(modes): with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: From 6c6408e87f5f350421f5b2f6792188f542b67b4e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 5 Nov 2025 18:28:24 +0100 Subject: [PATCH 130/161] refactor(monaco_dock): changed to use DockAreaWidget --- .../widgets/editors/monaco/monaco_dock.py | 119 ++++++++++-------- 1 file changed, 64 insertions(+), 55 deletions(-) diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py index 25e8392a7..8b8cd1912 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_dock.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -7,17 +7,16 @@ from bec_lib.logger import bec_logger from bec_lib.macro_update_handler import has_executable_code from qtpy.QtCore import QEvent, QTimer, Signal -from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget +from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget -import bec_widgets.widgets.containers.ads as QtAds -from bec_widgets import BECWidget from bec_widgets.widgets.containers.ads import CDockAreaWidget, CDockWidget +from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget logger = bec_logger.logger -class MonacoDock(BECWidget, QWidget): +class MonacoDock(DockAreaWidget): """ MonacoDock is a dock widget that contains Monaco editor instances. It is used to manage multiple Monaco editors in a dockable interface. @@ -29,55 +28,34 @@ class MonacoDock(BECWidget, QWidget): macro_file_updated = Signal(str) # Emitted when a macro file is saved def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) - # Top-level layout hosting a toolbar and the dock manager - self._root_layout = QVBoxLayout(self) - self._root_layout.setContentsMargins(0, 0, 0, 0) - self._root_layout.setSpacing(0) - - self.dock_manager = QtAds.CDockManager(self) - self.dock_manager.setStyleSheet("") + super().__init__( + parent=parent, + variant="compact", + title="Monaco Editors", + default_add_direction="top", + **kwargs, + ) self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event) - self._root_layout.addWidget(self.dock_manager) self.dock_manager.installEventFilter(self) self._last_focused_editor: CDockWidget | None = None self.focused_editor.connect(self._on_last_focused_editor_changed) - self.add_editor() - self._open_files = {} + initial_editor = self.add_editor() + if isinstance(initial_editor, CDockWidget): + self.last_focused_editor = initial_editor - def _create_editor(self): + def _create_editor_widget(self) -> MonacoWidget: + """Create a configured Monaco editor widget.""" init_lsp = len(self.dock_manager.dockWidgets()) == 0 widget = MonacoWidget(self, init_lsp=init_lsp) widget.save_enabled.connect(self.save_enabled.emit) widget.editor.signature_help_triggered.connect(self._on_signature_change) - count = len(self.dock_manager.dockWidgets()) - dock = CDockWidget(f"Untitled_{count + 1}") - dock.setWidget(widget) - - # Connect to modification status changes to update tab titles - widget.save_enabled.connect( - lambda modified: self._update_tab_title_for_modification(dock, modified) - ) - - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) - dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, True) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, True) - - dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget)) - - return dock + return widget @property def last_focused_editor(self) -> CDockWidget | None: """ Get the last focused editor. """ - dock_widget = self.dock_manager.focusedDockWidget() - if dock_widget is not None and isinstance(dock_widget.widget(), MonacoWidget): - self.last_focused_editor = dock_widget - return self._last_focused_editor @last_focused_editor.setter @@ -221,27 +199,55 @@ def eventFilter(self, obj, event): def add_editor( self, area: Any | None = None, title: str | None = None, tooltip: str | None = None - ): # Any as qt ads does not return a proper type + ) -> CDockWidget: """ - Adds a new Monaco editor dock widget to the dock manager. + Add a new Monaco editor dock to the specified area. + + Args: + area(Any | None): The area to add the editor to. If None, adds to the main area. + title(str | None): The title of the editor tab. If None, a default title is used. + tooltip(str | None): The tooltip for the editor tab. If None, no tooltip is set. + + Returns: + CDockWidget: The created dock widget containing the Monaco editor. """ - new_dock = self._create_editor() - if title is not None: - new_dock.setWindowTitle(title) + widget = self._create_editor_widget() + existing_count = len(self.dock_manager.dockWidgets()) + default_title = title or f"Untitled_{existing_count + 1}" + + tab_target: CDockWidget | None = None + if isinstance(area, CDockAreaWidget): + tab_target = area.currentDockWidget() + if tab_target is None: + docks = area.dockWidgets() + tab_target = docks[0] if docks else None + + dock = self.new( + widget, + closable=True, + floatable=False, + movable=True, + tab_with=tab_target, + return_dock=True, + on_close=self._on_editor_close_requested, + title_buttons={"float": False}, + where="right", + ) + dock.setWindowTitle(default_title) if tooltip is not None: - new_dock.setTabToolTip(tooltip) - if area is None: - area_obj = self.dock_manager.addDockWidgetTab( - QtAds.DockWidgetArea.TopDockWidgetArea, new_dock - ) - self._ensure_area_plus(area_obj) - else: - # If an area is provided, add the dock to that area - self.dock_manager.addDockWidgetTabToArea(new_dock, area) - self._ensure_area_plus(area) + dock.setTabToolTip(tooltip) + + widget.save_enabled.connect( + lambda modified, target=dock: self._update_tab_title_for_modification(target, modified) + ) + + area_widget = dock.dockAreaWidget() + if area_widget is not None: + self._ensure_area_plus(area_widget) QTimer.singleShot(0, self._scan_and_fix_areas) - return new_dock + self.last_focused_editor = dock + return dock def open_file(self, file_name: str, scope: str | None = None) -> None: """ @@ -252,6 +258,7 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: dock = self._get_editor_dock(file_name) if dock is not None: dock.setAsCurrentTab() + self.last_focused_editor = dock return file = os.path.basename(file_name) @@ -276,6 +283,7 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: editor_widget.open_file(file_name) if scope is not None: editor_widget.metadata["scope"] = scope + self.last_focused_editor = editor_dock return # File is not open, create a new editor @@ -285,6 +293,7 @@ def open_file(self, file_name: str, scope: str | None = None) -> None: if scope is not None: widget.metadata["scope"] = scope editor_dock.setAsCurrentTab() + self.last_focused_editor = editor_dock def save_file( self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True @@ -415,7 +424,7 @@ def _get_open_files(self) -> list[str]: open_files.append(editor_widget.current_file) return open_files - def _get_editor_dock(self, file_name: str) -> QtAds.CDockWidget | None: + def _get_editor_dock(self, file_name: str) -> CDockWidget | None: for widget in self.dock_manager.dockWidgets(): editor_widget = cast(MonacoWidget, widget.widget()) if editor_widget.current_file == file_name: From 00e4786651a19c02c287a95ee44ed439f2cb80e5 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 5 Nov 2025 18:28:39 +0100 Subject: [PATCH 131/161] refactor(developer_view): changed to use DockAreaWidget --- .../views/developer_view/developer_view.py | 3 - .../views/developer_view/developer_widget.py | 157 ++++++++++-------- 2 files changed, 89 insertions(+), 71 deletions(-) diff --git a/bec_widgets/applications/views/developer_view/developer_view.py b/bec_widgets/applications/views/developer_view/developer_view.py index 3b28a3920..6f177c752 100644 --- a/bec_widgets/applications/views/developer_view/developer_view.py +++ b/bec_widgets/applications/views/developer_view/developer_view.py @@ -21,9 +21,6 @@ def __init__( self.developer_widget = DeveloperWidget(parent=self) self.set_content(self.developer_widget) - # Apply stretch after the layout is done - self.set_default_view([2, 5, 3], [7, 3]) - if __name__ == "__main__": import sys diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 9e16bceb7..38a6c856c 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -5,17 +5,14 @@ from bec_lib.script_executor import upload_script from bec_qthemes import material_icon from qtpy.QtGui import QKeySequence, QShortcut -from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget -from shiboken6 import isValid +from qtpy.QtWidgets import QTextEdit -import bec_widgets.widgets.containers.ads as QtAds -from bec_widgets import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.widgets.containers.ads import CDockManager, CDockWidget from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock from bec_widgets.widgets.editors.web_console.web_console import WebConsole from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -77,31 +74,38 @@ def replace_python_examples(match): return css + html -class DeveloperWidget(BECWidget, QWidget): +class DeveloperWidget(DockAreaWidget): def __init__(self, parent=None, **kwargs): - super().__init__(parent=parent, **kwargs) + super().__init__(parent=parent, variant="compact", **kwargs) - # Top-level layout hosting a toolbar and the dock manager - self._root_layout = QVBoxLayout(self) - self._root_layout.setContentsMargins(0, 0, 0, 0) - self._root_layout.setSpacing(0) + # Promote toolbar above the dock manager provided by the base class self.toolbar = ModularToolBar(self) self.init_developer_toolbar() - self._root_layout.addWidget(self.toolbar) - - self.dock_manager = CDockManager(self) - self.dock_manager.setStyleSheet("") - self._root_layout.addWidget(self.dock_manager) + self._root_layout.insertWidget(0, self.toolbar) # Initialize the widgets self.explorer = IDEExplorer(self) + self.explorer.setObjectName("Explorer") self.console = WebConsole(self) + self.console.setObjectName("Console") self.terminal = WebConsole(self, startup_cmd="") + self.terminal.setObjectName("Terminal") self.monaco = MonacoDock(self) + self.monaco.setObjectName("Monaco Editor") self.monaco.save_enabled.connect(self._on_save_enabled_update) - self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom") + self.plotting_ads = AdvancedDockArea( + self, + mode="plot", + default_add_direction="bottom", + profile_namespace="developer_plotting", + auto_profile_namespace=False, + enable_profile_management=False, + variant="compact", + ) + self.plotting_ads.setObjectName("Plotting Area") self.signature_help = QTextEdit(self) + self.signature_help.setObjectName("Signature Help") self.signature_help.setAcceptRichText(True) self.signature_help.setReadOnly(True) self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) @@ -113,56 +117,83 @@ def __init__(self, parent=None, **kwargs): ) self._current_script_id: str | None = None - # Create the dock widgets - self.explorer_dock = QtAds.CDockWidget("Explorer", self) - self.explorer_dock.setWidget(self.explorer) - - self.console_dock = QtAds.CDockWidget("Console", self) - self.console_dock.setWidget(self.console) + self._initialize_layout() - self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self) - self.monaco_dock.setWidget(self.monaco) - - self.terminal_dock = QtAds.CDockWidget("Terminal", self) - self.terminal_dock.setWidget(self.terminal) + # Connect editor signals + self.explorer.file_open_requested.connect(self._open_new_file) + self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) - # Monaco will be central widget - self.dock_manager.setCentralWidget(self.monaco_dock) + self.toolbar.show_bundles(["save", "execution", "settings"]) - # Add the dock widgets to the dock manager - area_bottom = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock + def _initialize_layout(self) -> None: + """Create the default dock arrangement for the developer workspace.""" + + # Monaco editor as the central dock + self.monaco_dock = self.new( + self.monaco, + closable=False, + floatable=False, + movable=False, + return_dock=True, + show_title_bar=False, + show_settings_action=False, + title_buttons={"float": False, "close": False, "menu": False}, + # promote_central=True, ) - self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom) - area_left = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock + # Explorer on the left without a title bar + self.explorer_dock = self.new( + self.explorer, + where="left", + closable=False, + floatable=False, + movable=False, + return_dock=True, + show_title_bar=False, ) - area_left.titleBar().setVisible(False) - - for dock in self.dock_manager.dockWidgets(): - # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea - # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, False) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, False) - - self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self) - self.plotting_ads_dock.setWidget(self.plotting_ads) - self.signature_dock = QtAds.CDockWidget("Signature Help", self) - self.signature_dock.setWidget(self.signature_help) - - area_right = self.dock_manager.addDockWidget( - QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock + # Console and terminal tabbed along the bottom + self.console_dock = self.new( + self.console, + relative_to=self.monaco_dock, + where="bottom", + closable=False, + floatable=False, + movable=False, + return_dock=True, + title_buttons={"float": True, "close": False}, + ) + self.terminal_dock = self.new( + self.terminal, + closable=False, + floatable=False, + movable=False, + tab_with=self.console_dock, + return_dock=True, + title_buttons={"float": False, "close": False}, ) - self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right) - # Connect editor signals - self.explorer.file_open_requested.connect(self._open_new_file) - self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) + # Plotting area on the right with signature help tabbed alongside + self.plotting_ads_dock = self.new( + self.plotting_ads, + where="right", + closable=False, + floatable=False, + movable=False, + return_dock=True, + title_buttons={"float": True}, + ) + self.signature_dock = self.new( + self.signature_help, + closable=False, + floatable=False, + movable=False, + tab_with=self.plotting_ads_dock, + return_dock=True, + title_buttons={"float": False, "close": False}, + ) - self.toolbar.show_bundles(["save", "execution", "settings"]) + self.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3]) def init_developer_toolbar(self): """Initialize the developer toolbar with necessary actions and widgets.""" @@ -313,19 +344,9 @@ def on_script_execution_info(self, content: dict, metadata: dict): self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) def cleanup(self): - for dock in self.dock_manager.dockWidgets(): - self._delete_dock(dock) + self.delete_all() return super().cleanup() - 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() - if __name__ == "__main__": import sys From bd2d47f18f2fdff0656cd1007379e28f0d91acfb Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 9 Nov 2025 14:52:42 +0100 Subject: [PATCH 132/161] refactor(main_app): adapted for DockAreaWidget changes --- bec_widgets/applications/main_app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index 64a6b6053..2eccb7006 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -47,7 +47,10 @@ def __init__( def _add_views(self): self.add_section("BEC Applications", "bec_apps") - self.ads = AdvancedDockArea(self) + self.ads = AdvancedDockArea( + self, profile_namespace="main_workspace", auto_profile_namespace=False + ) + self.ads.setObjectName("MainWorkspace") self.device_manager = DeviceManagerWidget(self) self.developer_view = DeveloperView(self) From 22651f1c58ef45116964ea88507e6b7f37d206ee Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 19 Nov 2025 11:29:36 +0100 Subject: [PATCH 133/161] fix(widget_state_manager): visible is always true --- bec_widgets/utils/widget_state_manager.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index e5f94bdc5..56cbd241e 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -97,8 +97,12 @@ def _save_widget_state_qsettings( for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() + skip_visible = ( + name == "visible" + ) # TODO long term wise should be figured it out on the level of ADS, hotfix for now to avoid saving visibility when ads is not on main page if ( name == "objectName" + or skip_visible or not prop.isReadable() or not prop.isWritable() or not prop.isStored() # can be extended to fine filter From 0119332bcfe8f8fb04755902703bdf69b816755e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 19 Nov 2025 15:42:46 +0100 Subject: [PATCH 134/161] fix(ide_explorer): light mode fixed --- .../widgets/containers/explorer/collapsible_tree_section.py | 3 +-- bec_widgets/widgets/utility/ide_explorer/ide_explorer.py | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index 5eec83d75..3c9d9863f 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -75,7 +75,7 @@ def __init__( self.header_add_button.setToolButtonStyle(Qt.ToolButtonIconOnly) self.header_add_button.setAutoRaise(True) - self.header_add_button.setIcon(material_icon("add", size=(28, 28))) + self.header_add_button.setIcon(material_icon("add", size=(28, 28), convert_to_pixmap=False)) header_layout.addWidget(self.header_add_button) self.main_layout.addLayout(header_layout) @@ -118,7 +118,6 @@ def _update_appearance(self): padding: 0px; border: none; background: transparent; - color: {text_color}; icon-size: 20px 20px; }} """ diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index d5eda4b5b..2c19a1760 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -117,7 +117,9 @@ def add_macro_section(self): show_add_button=True, tooltip="Macros are reusable functions that can be called from scripts or the console.", ) - section.header_add_button.setIcon(material_icon("refresh", size=(20, 20))) + section.header_add_button.setIcon( + material_icon("refresh", size=(20, 20), convert_to_pixmap=False) + ) section.header_add_button.setToolTip("Reload all macros") section.header_add_button.clicked.connect(self._reload_macros) From a9039c5ee66ad39ccb8919e277decc729094303c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 19 Nov 2025 15:46:55 +0100 Subject: [PATCH 135/161] fix(qt_ads): pythons stubs match structure of PySide6QtAds --- bec_widgets/__init__.py | 3 +- .../device_manager_view.py | 11 ++-- .../advanced_dock_area/advanced_dock_area.py | 4 +- .../advanced_dock_area/basic_dock_area.py | 43 +++++++------- .../advanced_dock_area/profile_utils.py | 3 +- .../containers/{ads => qt_ads}/__init__.py | 0 .../containers/{ads => qt_ads}/__init__.pyi | 56 +----------------- .../widgets/containers/qt_ads/ads/__init__.py | 0 .../containers/qt_ads/ads/__init__.pyi | 58 +++++++++++++++++++ .../widgets/editors/monaco/monaco_dock.py | 2 +- 10 files changed, 93 insertions(+), 87 deletions(-) rename bec_widgets/widgets/containers/{ads => qt_ads}/__init__.py (100%) rename bec_widgets/widgets/containers/{ads => qt_ads}/__init__.pyi (97%) create mode 100644 bec_widgets/widgets/containers/qt_ads/ads/__init__.py create mode 100644 bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi diff --git a/bec_widgets/__init__.py b/bec_widgets/__init__.py index 3d7d19fbd..f88f7db64 100644 --- a/bec_widgets/__init__.py +++ b/bec_widgets/__init__.py @@ -1,8 +1,7 @@ import os import sys -import PySide6QtAds as QtAds - +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_view.py b/bec_widgets/applications/views/device_manager_view/device_manager_view.py index 9acdb5a38..3029adae3 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_view.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -4,7 +4,6 @@ from functools import partial from typing import List, Literal -import PySide6QtAds as QtAds import yaml from bec_lib import config_helper from bec_lib.bec_yaml_loader import yaml_load @@ -12,7 +11,6 @@ from bec_lib.logger import bec_logger from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from bec_qthemes import apply_theme -from PySide6QtAds import CDockManager, CDockWidget from qtpy.QtCore import Qt, QThreadPool, QTimer from qtpy.QtWidgets import ( QDialog, @@ -28,6 +26,7 @@ QWidget, ) +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets import BECWidget from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.help_inspector.help_inspector import HelpInspector @@ -158,7 +157,7 @@ def __init__(self, parent=None, *args, **kwargs): self._root_layout = QVBoxLayout(self) self._root_layout.setContentsMargins(0, 0, 0, 0) self._root_layout.setSpacing(0) - self.dock_manager = CDockManager(self) + self.dock_manager = QtAds.CDockManager(self) self.dock_manager.setStyleSheet("") self._root_layout.addWidget(self.dock_manager) @@ -237,9 +236,9 @@ def __init__(self, parent=None, *args, **kwargs): self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area) for dock in self.dock_manager.dockWidgets(): - dock.setFeature(CDockWidget.DockWidgetClosable, False) - dock.setFeature(CDockWidget.DockWidgetFloatable, False) - dock.setFeature(CDockWidget.DockWidgetMovable, False) + dock.setFeature(QtAds.CDockWidget.DockWidgetClosable, False) + dock.setFeature(QtAds.CDockWidget.DockWidgetFloatable, False) + dock.setFeature(QtAds.CDockWidget.DockWidgetMovable, False) # Apply stretch after the layout is done self.set_default_view([2, 8, 2], [7, 3]) 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 a7a062ba1..b02f5c1db 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 @@ -3,9 +3,7 @@ import os from typing import Callable, Literal, Mapping, Sequence -import PySide6QtAds as QtAds from bec_lib import bec_logger -from PySide6QtAds import CDockWidget from qtpy.QtCore import QTimer, Signal from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( @@ -18,6 +16,7 @@ QWidget, ) +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils import BECDispatcher @@ -66,6 +65,7 @@ workspace_bundle, ) from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC +from bec_widgets.widgets.containers.qt_ads import CDockWidget from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D from bec_widgets.widgets.control.scan_control import ScanControl from bec_widgets.widgets.editors.web_console.web_console import WebConsole diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index 09898569e..7334bd85c 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -5,18 +5,17 @@ from typing import Any, Callable, Literal, Mapping, Sequence, cast from bec_qthemes import material_icon -from PySide6QtAds import ads from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer from qtpy.QtGui import QIcon from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget from shiboken6 import isValid -import bec_widgets.widgets.containers.ads as QtAds +import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets import BECWidget, SafeSlot from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils.property_editor import PropertyEditor from bec_widgets.utils.toolbars.actions import MaterialIconAction -from bec_widgets.widgets.containers.ads import ( +from bec_widgets.widgets.containers.qt_ads import ( CDockAreaWidget, CDockManager, CDockSplitter, @@ -70,7 +69,7 @@ class DockCreationSpec: tab_with: CDockWidget | None = None relative_to: CDockWidget | None = None title_visible: bool | None = None - title_buttons: Mapping[ads.TitleBarButton, bool] | None = None + title_buttons: Mapping[QtAds.ads.TitleBarButton, bool] | None = None show_settings_action: bool | None = False dock_preferences: Mapping[str, Any] | None = None promote_central: bool = False @@ -979,38 +978,38 @@ def set_layout_ratios( self._apply_splitter_tree(splitter, (), horizontal, vertical, overrides) @staticmethod - def _title_bar_button_enum(name: str) -> ads.TitleBarButton | None: + def _title_bar_button_enum(name: str) -> QtAds.ads.TitleBarButton | None: """Translate a user-friendly button name into an ADS TitleBarButton enum.""" normalized = (name or "").lower().replace("-", "_").replace(" ", "_") - mapping: dict[str, ads.TitleBarButton] = { - "menu": ads.TitleBarButton.TitleBarButtonTabsMenu, - "tabs_menu": ads.TitleBarButton.TitleBarButtonTabsMenu, - "tabs": ads.TitleBarButton.TitleBarButtonTabsMenu, - "undock": ads.TitleBarButton.TitleBarButtonUndock, - "float": ads.TitleBarButton.TitleBarButtonUndock, - "detach": ads.TitleBarButton.TitleBarButtonUndock, - "close": ads.TitleBarButton.TitleBarButtonClose, - "auto_hide": ads.TitleBarButton.TitleBarButtonAutoHide, - "autohide": ads.TitleBarButton.TitleBarButtonAutoHide, - "minimize": ads.TitleBarButton.TitleBarButtonMinimize, + mapping: dict[str, QtAds.ads.TitleBarButton] = { + "menu": QtAds.ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs_menu": QtAds.ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs": QtAds.ads.TitleBarButton.TitleBarButtonTabsMenu, + "undock": QtAds.ads.TitleBarButton.TitleBarButtonUndock, + "float": QtAds.ads.TitleBarButton.TitleBarButtonUndock, + "detach": QtAds.ads.TitleBarButton.TitleBarButtonUndock, + "close": QtAds.ads.TitleBarButton.TitleBarButtonClose, + "auto_hide": QtAds.ads.TitleBarButton.TitleBarButtonAutoHide, + "autohide": QtAds.ads.TitleBarButton.TitleBarButtonAutoHide, + "minimize": QtAds.ads.TitleBarButton.TitleBarButtonMinimize, } return mapping.get(normalized) def _normalize_title_buttons( self, spec: ( - Mapping[str | ads.TitleBarButton, bool] - | Sequence[str | ads.TitleBarButton] + Mapping[str | QtAds.ads.TitleBarButton, bool] + | Sequence[str | QtAds.ads.TitleBarButton] | str - | ads.TitleBarButton + | QtAds.ads.TitleBarButton | None ), - ) -> dict[ads.TitleBarButton, bool]: + ) -> dict[QtAds.ads.TitleBarButton, bool]: """Normalize button visibility specifications into an enum mapping.""" if spec is None: return {} - result: dict[ads.TitleBarButton, bool] = {} + result: dict[QtAds.ads.TitleBarButton, bool] = {} if isinstance(spec, Mapping): iterator = spec.items() else: @@ -1019,7 +1018,7 @@ def _normalize_title_buttons( iterator = ((name, False) for name in spec) for name, visible in iterator: - if isinstance(name, ads.TitleBarButton): + if isinstance(name, QtAds.ads.TitleBarButton): enum = name else: enum = self._title_bar_button_enum(str(name)) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 59c4847bd..9e239b033 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -20,10 +20,11 @@ from bec_lib.client import BECClient from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from pydantic import BaseModel, Field -from PySide6QtAds import CDockWidget from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt from qtpy.QtGui import QPixmap +from bec_widgets.widgets.containers.qt_ads import CDockWidget + logger = bec_logger.logger MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) diff --git a/bec_widgets/widgets/containers/ads/__init__.py b/bec_widgets/widgets/containers/qt_ads/__init__.py similarity index 100% rename from bec_widgets/widgets/containers/ads/__init__.py rename to bec_widgets/widgets/containers/qt_ads/__init__.py diff --git a/bec_widgets/widgets/containers/ads/__init__.pyi b/bec_widgets/widgets/containers/qt_ads/__init__.pyi similarity index 97% rename from bec_widgets/widgets/containers/ads/__init__.pyi rename to bec_widgets/widgets/containers/qt_ads/__init__.pyi index 7bb78f08b..dfc1232f4 100644 --- a/bec_widgets/widgets/containers/ads/__init__.pyi +++ b/bec_widgets/widgets/containers/qt_ads/__init__.pyi @@ -7,60 +7,10 @@ import typing from qtpy import QtCore, QtGui, QtWidgets from qtpy.QtCore import Signal -# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called - -class SideBarLocation(enum.Enum): - SideBarTop = ... - SideBarLeft = ... - SideBarRight = ... - SideBarBottom = ... - SideBarNone = ... - -class eBitwiseOperator(enum.Enum): - BitwiseAnd = ... - BitwiseOr = ... - -class eIcon(enum.Enum): - TabCloseIcon = ... - AutoHideIcon = ... - DockAreaMenuIcon = ... - DockAreaUndockIcon = ... - DockAreaCloseIcon = ... - DockAreaMinimizeIcon = ... - IconCount = ... +from bec_widgets.widgets.containers.qt_ads import ads +from bec_widgets.widgets.containers.qt_ads.ads import * -class eDragState(enum.Enum): - DraggingInactive = ... - DraggingMousePressed = ... - DraggingTab = ... - DraggingFloatingWidget = ... - -class TitleBarButton(enum.Enum): - TitleBarButtonTabsMenu = ... - TitleBarButtonUndock = ... - TitleBarButtonClose = ... - TitleBarButtonAutoHide = ... - TitleBarButtonMinimize = ... - -class eTabIndex(enum.Enum): - TabDefaultInsertIndex = ... - TabInvalidIndex = ... - -class DockWidgetArea(enum.Enum): - NoDockWidgetArea = ... - LeftDockWidgetArea = ... - RightDockWidgetArea = ... - TopDockWidgetArea = ... - BottomDockWidgetArea = ... - CenterDockWidgetArea = ... - LeftAutoHideArea = ... - RightAutoHideArea = ... - TopAutoHideArea = ... - BottomAutoHideArea = ... - InvalidDockWidgetArea = ... - OuterDockAreas = ... - AutoHideDockAreas = ... - AllDockAreas = ... +# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called class CAutoHideDockContainer(QtWidgets.QFrame): def __init__( diff --git a/bec_widgets/widgets/containers/qt_ads/ads/__init__.py b/bec_widgets/widgets/containers/qt_ads/ads/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi b/bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi new file mode 100644 index 000000000..30b3a90af --- /dev/null +++ b/bec_widgets/widgets/containers/qt_ads/ads/__init__.pyi @@ -0,0 +1,58 @@ +from __future__ import annotations + +import enum + +# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called + +class SideBarLocation(enum.Enum): + SideBarTop = ... + SideBarLeft = ... + SideBarRight = ... + SideBarBottom = ... + SideBarNone = ... + +class eBitwiseOperator(enum.Enum): + BitwiseAnd = ... + BitwiseOr = ... + +class eIcon(enum.Enum): + TabCloseIcon = ... + AutoHideIcon = ... + DockAreaMenuIcon = ... + DockAreaUndockIcon = ... + DockAreaCloseIcon = ... + DockAreaMinimizeIcon = ... + IconCount = ... + +class eDragState(enum.Enum): + DraggingInactive = ... + DraggingMousePressed = ... + DraggingTab = ... + DraggingFloatingWidget = ... + +class TitleBarButton(enum.Enum): + TitleBarButtonTabsMenu = ... + TitleBarButtonUndock = ... + TitleBarButtonClose = ... + TitleBarButtonAutoHide = ... + TitleBarButtonMinimize = ... + +class eTabIndex(enum.Enum): + TabDefaultInsertIndex = ... + TabInvalidIndex = ... + +class DockWidgetArea(enum.Enum): + NoDockWidgetArea = ... + LeftDockWidgetArea = ... + RightDockWidgetArea = ... + TopDockWidgetArea = ... + BottomDockWidgetArea = ... + CenterDockWidgetArea = ... + LeftAutoHideArea = ... + RightAutoHideArea = ... + TopAutoHideArea = ... + BottomAutoHideArea = ... + InvalidDockWidgetArea = ... + OuterDockAreas = ... + AutoHideDockAreas = ... + AllDockAreas = ... diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py index 8b8cd1912..3186d82c4 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_dock.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -9,8 +9,8 @@ from qtpy.QtCore import QEvent, QTimer, Signal from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget -from bec_widgets.widgets.containers.ads import CDockAreaWidget, CDockWidget from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.qt_ads import CDockAreaWidget, CDockWidget from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget logger = bec_logger.logger From 86a966f10fd0306ef2fceacb0442e2bd8741a7dc Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 19 Nov 2025 16:17:28 +0100 Subject: [PATCH 136/161] fix(widget_io): find ancestor returns correct type --- bec_widgets/utils/widget_io.py | 61 +++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/bec_widgets/utils/widget_io.py b/bec_widgets/utils/widget_io.py index 92c9f295d..443eb814e 100644 --- a/bec_widgets/utils/widget_io.py +++ b/bec_widgets/utils/widget_io.py @@ -2,8 +2,10 @@ from __future__ import annotations from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Type, TypeVar, cast import shiboken6 as shb +from bec_lib import bec_logger from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -21,6 +23,13 @@ from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.utils import BECConnector + +logger = bec_logger.logger + +TAncestor = TypeVar("TAncestor", bound=QWidget) + class WidgetHandler(ABC): """Abstract base class for all widget handlers.""" @@ -576,44 +585,50 @@ def get_bec_connectors_from_parent(widget) -> list: return connectors @staticmethod - def find_ancestor(widget, ancestor_class) -> QWidget | None: + def find_ancestor( + widget: QWidget | BECConnector, ancestor_class: Type[TAncestor] | str + ) -> TAncestor | 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. + Find the closest ancestor of the specified class (or class-name string). + + Args: + widget(QWidget): The starting widget. + ancestor_class(Type[TAncestor] | str): The ancestor class or class-name string to search for. + + Returns: + TAncestor | None: The closest ancestor of the specified class, or None if not found. """ - # Guard against deleted/invalid Qt wrappers - if not shb.isValid(widget): + if widget is None or not shb.isValid(widget): return None - # If searching for BECConnector specifically, reuse the dedicated helper try: from bec_widgets.utils import BECConnector # local import to avoid cycles - if ancestor_class is BECConnector or ( - isinstance(ancestor_class, str) and ancestor_class == "BECConnector" - ): - return WidgetHierarchy._get_becwidget_ancestor(widget) - except Exception: - # If import fails, fall back to generic traversal below - pass - - # Generic traversal across QObject parent chain - parent = getattr(widget, "parent", None) - if callable(parent): - parent = parent() + is_bec_target = False + if isinstance(ancestor_class, str): + is_bec_target = ancestor_class == "BECConnector" + elif isinstance(ancestor_class, type): + is_bec_target = issubclass(ancestor_class, BECConnector) + + if is_bec_target: + ancestor = WidgetHierarchy._get_becwidget_ancestor(widget) + return cast(TAncestor, ancestor) + except Exception as e: + logger.error(f"Error importing BECConnector: {e}") + + parent = widget.parent() if hasattr(widget, "parent") else None while parent is not None: if not shb.isValid(parent): return None try: if isinstance(ancestor_class, str): if parent.__class__.__name__ == ancestor_class: - return parent + return cast(TAncestor, parent) else: if isinstance(parent, ancestor_class): - return parent - except Exception: - pass + return cast(TAncestor, parent) + except Exception as e: + logger.error(f"Error checking ancestor class: {e}") parent = parent.parent() if hasattr(parent, "parent") else None return None From 31d01abe1855e1310ac7a46769aca1eae3a74f99 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 19 Nov 2025 16:18:36 +0100 Subject: [PATCH 137/161] fix(bec_widget): improved qt enums; grab safeguard --- bec_widgets/utils/bec_widget.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 1660f58c0..dd94d0c81 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -3,13 +3,13 @@ from datetime import datetime from typing import TYPE_CHECKING -import PySide6QtAds as QtAds import shiboken6 from bec_lib.logger import bec_logger from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QApplication, QFileDialog, QWidget +import bec_widgets.widgets.containers.qt_ads as QtAds 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 SafeConnect, SafeSlot @@ -242,16 +242,22 @@ def screenshot_bytes( """ if not isinstance(self, QWidget): return QByteArray() + + if not hasattr(self, "grab"): + raise RuntimeError(f"Cannot take screenshot of non-QWidget instance: {repr(self)}") + pixmap: QPixmap = self.grab() if pixmap.isNull(): return QByteArray() if max_width is not None or max_height is not None: w = max_width if max_width is not None else pixmap.width() h = max_height if max_height is not None else pixmap.height() - pixmap = pixmap.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation) + pixmap = pixmap.scaled( + w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.QSmoothTransformation + ) ba = QByteArray() buf = QBuffer(ba) - buf.open(QIODevice.WriteOnly) + buf.open(QIODevice.OpenModeFlag.WriteOnly) pixmap.save(buf, fmt, quality) buf.close() return ba From 557371f3ba5035fe796d7bbb1bc268fd81cf788e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 10 Nov 2025 16:32:36 +0100 Subject: [PATCH 138/161] fix(client): client regenerated --- bec_widgets/cli/client.py | 396 +++++++++++++++++++++++++++++++++++--- 1 file changed, 365 insertions(+), 31 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index dda269f0a..c8339776e 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -98,46 +98,51 @@ class AdvancedDockArea(RPCBase): @rpc_call def new( self, - widget: "BECWidget | str", + widget: "QWidget | str", + *, closable: "bool" = True, floatable: "bool" = True, movable: "bool" = True, start_floating: "bool" = False, where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, - **kwargs, - ) -> "BECWidget": + on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, + tab_with: "CDockWidget | QWidget | str | None" = None, + relative_to: "CDockWidget | QWidget | str | None" = None, + return_dock: "bool" = False, + show_title_bar: "bool | None" = None, + title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None, + show_settings_action: "bool | None" = None, + promote_central: "bool" = False, + **widget_kwargs, + ) -> "QWidget | CDockWidget | BECWidget": """ - Create a new widget (or reuse an instance) and add it as a dock. + Override the base helper so dock settings are available by default. - Args: - widget: Widget instance or a string widget type (factory-created). - closable: Whether the dock is closable. - floatable: Whether the dock is floatable. - movable: Whether the dock is movable. - start_floating: Start the dock in a floating state. - where: Preferred area to add the dock: "left" | "right" | "top" | "bottom". - If None, uses the instance default passed at construction time. - **kwargs: The keyword arguments for the widget. - Returns: - The widget instance. + The flag remains user-configurable (pass ``False`` to hide the action). """ @rpc_call - def widget_map(self) -> "dict[str, QWidget]": + def dock_map(self) -> "dict[str, CDockWidget]": + """ + Return the dock widgets map as dictionary with names as keys. """ - Return a dictionary mapping widget names to their corresponding BECWidget instances. - Returns: - dict: A dictionary mapping widget names to BECWidget instances. + @rpc_call + def dock_list(self) -> "list[CDockWidget]": + """ + Return the list of dock widgets. """ @rpc_call - def widget_list(self) -> "list[QWidget]": + def widget_map(self) -> "dict[str, QWidget]": + """ + Return a dictionary mapping widget names to their corresponding widgets. """ - Return a list of all BECWidget instances in the dock area. - Returns: - list: A list of all BECWidget instances in the dock area. + @rpc_call + def widget_list(self) -> "list[QWidget]": + """ + Return a list of all widgets contained in the dock area. """ @property @@ -153,13 +158,58 @@ def lock_workspace(self) -> "bool": @rpc_call def attach_all(self): """ - Return all floating docks to the dock area, preserving tab groups within each floating container. + Re-attach floating docks back into the dock manager. """ @rpc_call def delete_all(self): """ - Delete all docks and widgets. + Delete all docks and their associated widgets. + """ + + @rpc_call + def set_layout_ratios( + self, + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + @rpc_call + def describe_layout(self) -> "list[dict[str, Any]]": + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + + @rpc_call + def print_layout_structure(self) -> "None": + """ + Pretty-print the current splitter paths to stdout. """ @property @@ -1290,6 +1340,159 @@ def _is_valid_input(self) -> bool: """ +class DockAreaWidget(RPCBase): + """Lightweight dock area that exposes the core Qt ADS docking helpers without any""" + + @rpc_call + def new( + self, + widget: "QWidget | str", + *, + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, + on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, + tab_with: "CDockWidget | QWidget | str | None" = None, + relative_to: "CDockWidget | QWidget | str | None" = None, + return_dock: "bool" = False, + show_title_bar: "bool | None" = None, + title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None, + show_settings_action: "bool | None" = False, + promote_central: "bool" = False, + dock_icon: "QIcon | None" = None, + apply_widget_icon: "bool" = True, + **widget_kwargs, + ) -> "QWidget | CDockWidget | BECWidget": + """ + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + return_dock(bool): When True, return the created dock instead of the widget. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + the widget's ``ICON_NAME`` attribute is used when available. + apply_widget_icon(bool): When False, skip automatically resolving the icon from + the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + + Returns: + The widget instance by default, or the created `CDockWidget` when `return_dock` is True. + """ + + @rpc_call + def dock_map(self) -> "dict[str, CDockWidget]": + """ + Return the dock widgets map as dictionary with names as keys. + """ + + @rpc_call + def dock_list(self) -> "list[CDockWidget]": + """ + Return the list of dock widgets. + """ + + @rpc_call + def widget_map(self) -> "dict[str, QWidget]": + """ + Return a dictionary mapping widget names to their corresponding widgets. + """ + + @rpc_call + def widget_list(self) -> "list[QWidget]": + """ + Return a list of all widgets contained in the dock area. + """ + + @rpc_call + def attach_all(self): + """ + Re-attach floating docks back into the dock manager. + """ + + @rpc_call + def delete_all(self): + """ + Delete all docks and their associated widgets. + """ + + @rpc_call + def set_layout_ratios( + self, + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + @rpc_call + def describe_layout(self) -> "list[dict[str, Any]]": + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + + @rpc_call + def print_layout_structure(self) -> "None": + """ + Pretty-print the current splitter paths to stdout. + """ + + @rpc_call + def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None": + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. + """ + + class EllipticalROI(RPCBase): """Elliptical Region of Interest with centre/width/height tracking and auto-labelling.""" @@ -2690,21 +2893,152 @@ class MonacoDock(RPCBase): """MonacoDock is a dock widget that contains Monaco editor instances.""" @rpc_call - def remove(self): + def new( + self, + widget: "QWidget | str", + *, + closable: "bool" = True, + floatable: "bool" = True, + movable: "bool" = True, + start_floating: "bool" = False, + where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, + on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, + tab_with: "CDockWidget | QWidget | str | None" = None, + relative_to: "CDockWidget | QWidget | str | None" = None, + return_dock: "bool" = False, + show_title_bar: "bool | None" = None, + title_buttons: "Mapping[str, bool] | Sequence[str] | str | None" = None, + show_settings_action: "bool | None" = False, + promote_central: "bool" = False, + dock_icon: "QIcon | None" = None, + apply_widget_icon: "bool" = True, + **widget_kwargs, + ) -> "QWidget | CDockWidget | BECWidget": """ - Cleanup the BECConnector + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + return_dock(bool): When True, return the created dock instead of the widget. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + the widget's ``ICON_NAME`` attribute is used when available. + apply_widget_icon(bool): When False, skip automatically resolving the icon from + the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + + Returns: + The widget instance by default, or the created `CDockWidget` when `return_dock` is True. """ @rpc_call - def attach(self): + def dock_map(self) -> "dict[str, CDockWidget]": """ - None + Return the dock widgets map as dictionary with names as keys. """ @rpc_call - def detach(self): + def dock_list(self) -> "list[CDockWidget]": """ - Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + Return the list of dock widgets. + """ + + @rpc_call + def widget_map(self) -> "dict[str, QWidget]": + """ + Return a dictionary mapping widget names to their corresponding widgets. + """ + + @rpc_call + def widget_list(self) -> "list[QWidget]": + """ + Return a list of all widgets contained in the dock area. + """ + + @rpc_call + def attach_all(self): + """ + Re-attach floating docks back into the dock manager. + """ + + @rpc_call + def delete_all(self): + """ + Delete all docks and their associated widgets. + """ + + @rpc_call + def set_layout_ratios( + self, + *, + horizontal: "Sequence[float] | Mapping[int | str, float] | None" = None, + vertical: "Sequence[float] | Mapping[int | str, float] | None" = None, + splitter_overrides: "Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None" = None, + ) -> "None": + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + @rpc_call + def describe_layout(self) -> "list[dict[str, Any]]": + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + + @rpc_call + def print_layout_structure(self) -> "None": + """ + Pretty-print the current splitter paths to stdout. + """ + + @rpc_call + def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None": + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. """ From aed22c605b88d0db16ee6686db7703a9a60e1435 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 11:11:35 +0100 Subject: [PATCH 139/161] fix(widget_state_manager): always setting visible to true --- bec_widgets/utils/widget_state_manager.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 56cbd241e..22da12082 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -97,12 +97,12 @@ def _save_widget_state_qsettings( for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() - skip_visible = ( - name == "visible" - ) # TODO long term wise should be figured it out on the level of ADS, hotfix for now to avoid saving visibility when ads is not on main page + if name == "visible": + settings.setValue( + name, True + ) # always save visible as True to avoid invisible widgets on load if ( name == "objectName" - or skip_visible or not prop.isReadable() or not prop.isWritable() or not prop.isStored() # can be extended to fine filter From 1b299b93344c572fa49c109e05ff4aaeb305aeb1 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 13:23:36 +0100 Subject: [PATCH 140/161] feat(widget_state_manager): can serialize from root --- bec_widgets/utils/widget_state_manager.py | 78 ++++++++++++++----- .../advanced_dock_area/advanced_dock_area.py | 4 +- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 22da12082..3a2ad87c2 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -23,14 +23,23 @@ class WidgetStateManager: """ - A class to manage the state of a widget by saving and loading the state to and from a INI file. + Manage saving and loading widget state to/from an INI file. Args: - widget(QWidget): The widget to manage the state for. + widget (QWidget): Root widget whose subtree will be serialized. + serialize_from_root (bool): When True, build group names relative to + this root and ignore parents above it. This keeps profiles portable + between different host window hierarchies. + root_id (str | None): Optional stable label to use for the root in + the settings key path. When omitted and `serialize_from_root` is + True, the class name of `widget` is used, falling back to its + objectName and finally to "root". """ - def __init__(self, widget): + def __init__(self, widget, *, serialize_from_root: bool = False, root_id: str | None = None): self.widget = widget + self._serialize_from_root = bool(serialize_from_root) + self._root_id = root_id def save_state(self, filename: str | None = None, settings: QSettings | None = None): """ @@ -97,10 +106,12 @@ def _save_widget_state_qsettings( for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() + + # Always save `visible` as True to avoid restoring hidden widgets from profiles. if name == "visible": - settings.setValue( - name, True - ) # always save visible as True to avoid invisible widgets on load + settings.setValue(name, True) + continue + if ( name == "objectName" or not prop.isReadable() @@ -108,6 +119,7 @@ def _save_widget_state_qsettings( or not prop.isStored() # can be extended to fine filter ): continue + value = widget.property(name) settings.setValue(name, value) settings.endGroup() @@ -178,23 +190,51 @@ def _load_widget_state_qsettings( ): self._load_widget_state_qsettings(child, settings, False) - def _get_full_widget_name(self, widget: QWidget): + def _get_full_widget_name(self, widget: QWidget) -> str: """ - Get the full name of the widget including its parent names. + Build a group key for *widget*. - Args: - widget(QWidget): The widget to get the full name for. + When `serialize_from_root` is False (default), this preserves the original + behavior and walks all parents up to the top-level widget. + + When `serialize_from_root` is True, the key is built relative to + `self.widget` and parents above the managed root are ignored. The first + path segment is either `root_id` (when provided) or a stable label derived + from the root widget (class name, then objectName, then "root"). - Returns: - str: The full name of the widget. + Args: + widget (QWidget): The widget to build the key for. """ - name = widget.objectName() - parent = widget.parent() - while parent: - obj_name = parent.objectName() or parent.metaObject().className() - name = obj_name + "." + name - parent = parent.parent() - return name + # Backwards-compatible behavior: include the entire parent chain. + if not getattr(self, "_serialize_from_root", False): + name = widget.objectName() + parent = widget.parent() + while parent: + obj_name = parent.objectName() or parent.metaObject().className() + name = obj_name + "." + name + parent = parent.parent() + return name + + parts: list[str] = [] + current: QWidget | None = widget + + while current is not None: + if current is self.widget: + # Reached the serialization root. + root_label = self._root_id + if not root_label: + meta = current.metaObject() if hasattr(current, "metaObject") else None + class_name = meta.className() if meta is not None else "" + root_label = class_name or current.objectName() or "root" + parts.append(str(root_label)) + break + + obj_name = current.objectName() or current.metaObject().className() + parts.append(obj_name) + current = current.parent() + + parts.reverse() + return ".".join(parts) class ExampleApp(QWidget): # pragma: no cover: 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 b02f5c1db..3bdadcf71 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 @@ -159,7 +159,9 @@ def __init__( self._exit_snapshot_written = False # State manager - self.state_manager = WidgetStateManager(self) + self.state_manager = WidgetStateManager( + self, serialize_from_root=True, root_id="AdvancedDockArea" + ) # Developer mode state self._editable = None From e74eea199e2e6aeac5a7bd184fac38f514e09c32 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 15:34:05 +0100 Subject: [PATCH 141/161] fix(widget_state_manager): skip property listed introduced --- bec_widgets/utils/widget_state_manager.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 3a2ad87c2..78066773a 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -20,6 +20,8 @@ logger = bec_logger.logger +PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText"] + class WidgetStateManager: """ @@ -114,6 +116,7 @@ def _save_widget_state_qsettings( if ( name == "objectName" + or name in PROPERTY_TO_SKIP or not prop.isReadable() or not prop.isWritable() or not prop.isStored() # can be extended to fine filter From e708ee56f44166efc6b22127f6b6cf40837fe642 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 20:55:14 +0100 Subject: [PATCH 142/161] fix(widget_state_manager): IDEExplorer plugin not initialised in designer --- .../widgets/utility/ide_explorer/ide_explorer_plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py index ce99a35e2..2c1c60bbd 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py @@ -1,7 +1,7 @@ # Copyright (C) 2022 The Qt Company Ltd. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause - from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget from bec_widgets.utils.bec_designer import designer_material_icon from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -20,6 +20,8 @@ def __init__(self): self._form_editor = None def createWidget(self, parent): + if parent is None: + return QWidget() t = IDEExplorer(parent) return t From 0f392efdce88ae0d62192e03668411c47997d729 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 15:33:41 +0100 Subject: [PATCH 143/161] fix(widgets): removed isVisible from all SafeProperties --- bec_widgets/utils/compact_popup.py | 4 ++- .../positioner_box/positioner_box.py | 4 ++- .../positioner_box_2d/positioner_box_2d.py | 8 +++-- .../control/scan_control/scan_control.py | 31 ++++++++++++------- .../widgets/dap/lmfit_dialog/lmfit_dialog.py | 12 +++++-- .../editors/scan_metadata/scan_metadata.py | 4 ++- bec_widgets/widgets/plots/plot_base.py | 25 +++++++++++---- .../scan_progressbar/scan_progressbar.py | 12 +++++-- .../widgets/services/bec_queue/bec_queue.py | 4 ++- 9 files changed, 74 insertions(+), 30 deletions(-) diff --git a/bec_widgets/utils/compact_popup.py b/bec_widgets/utils/compact_popup.py index 8d4daef24..af8b48a2d 100644 --- a/bec_widgets/utils/compact_popup.py +++ b/bec_widgets/utils/compact_popup.py @@ -144,6 +144,7 @@ def __init__(self, parent=None, layout=QVBoxLayout): self.container.setVisible(True) layout(self.container) self.layout = self.container.layout() + self._compact_view = False self.compact_show_popup.clicked.connect(self.show_popup) @@ -210,7 +211,7 @@ def addWidget(self, widget): @Property(bool) def compact_view(self): - return self.compact_label.isVisible() + return self._compact_view @compact_view.setter def compact_view(self, set_compact: bool): @@ -220,6 +221,7 @@ def compact_view(self, set_compact: bool): the full view is displayed. This is handled by toggling visibility of the container widget or the compact view widget. """ + self._compact_view = set_compact if set_compact: self.compact_view_widget.setVisible(True) self.container.setVisible(False) 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 a573623e6..28d779999 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 @@ -49,6 +49,7 @@ def __init__(self, parent=None, device: Positioner | str | None = None, **kwargs self._device = "" self._limits = None + self._hide_device_selection = False if self.current_path == "": self.current_path = os.path.dirname(__file__) @@ -114,11 +115,12 @@ def device(self, value: str): @SafeProperty(bool) def hide_device_selection(self): """Hide the device selection""" - return not self.ui.tool_button.isVisible() + return self._hide_device_selection @hide_device_selection.setter def hide_device_selection(self, value: bool): """Set the device selection visibility""" + self._hide_device_selection = value self.ui.tool_button.setVisible(not value) @SafeSlot(bool) 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 fdb5df06d..819af79c9 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 @@ -63,6 +63,8 @@ def __init__( self._limits_hor = None self._limits_ver = None self._dialog = None + self._hide_device_selection = False + self._hide_device_boxes = False if self.current_path == "": self.current_path = os.path.dirname(__file__) self.init_ui() @@ -213,22 +215,24 @@ def device_ver(self, value: str): @SafeProperty(bool) def hide_device_selection(self): """Hide the device selection""" - return not self.ui.tool_button_hor.isVisible() + return self._hide_device_selection @hide_device_selection.setter def hide_device_selection(self, value: bool): """Set the device selection visibility""" + self._hide_device_selection = value self.ui.tool_button_hor.setVisible(not value) self.ui.tool_button_ver.setVisible(not value) @SafeProperty(bool) def hide_device_boxes(self): """Hide the device selection""" - return not self.ui.device_box_hor.isVisible() + return self._hide_device_boxes @hide_device_boxes.setter def hide_device_boxes(self, value: bool): """Set the device selection visibility""" + self._hide_device_boxes = value self.ui.device_box_hor.setVisible(not value) self.ui.device_box_ver.setVisible(not value) diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 6bbef6e08..f1ef4b2b7 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -91,6 +91,11 @@ def __init__( self._scan_metadata: dict | None = None self._metadata_form = ScanMetadata(parent=self) + self._hide_arg_box = False + self._hide_kwarg_boxes = False + self._hide_scan_control_buttons = False + self._hide_metadata = False + self._hide_scan_selection_combobox = False # Create and set main layout self._init_UI() @@ -262,9 +267,7 @@ def set_current_scan(self, scan_name: str): @SafeProperty(bool) def hide_arg_box(self): """Property to hide the argument box.""" - if self.arg_box is None: - return True - return not self.arg_box.isVisible() + return self._hide_arg_box @hide_arg_box.setter def hide_arg_box(self, hide: bool): @@ -273,18 +276,14 @@ def hide_arg_box(self, hide: bool): Args: hide(bool): Hide or show the argument box. """ + self._hide_arg_box = hide if self.arg_box is not None: self.arg_box.setVisible(not hide) @SafeProperty(bool) def hide_kwarg_boxes(self): """Property to hide the keyword argument boxes.""" - if len(self.kwarg_boxes) == 0: - return True - - for box in self.kwarg_boxes: - if box is not None: - return not box.isVisible() + return self._hide_kwarg_boxes @hide_kwarg_boxes.setter def hide_kwarg_boxes(self, hide: bool): @@ -293,6 +292,7 @@ def hide_kwarg_boxes(self, hide: bool): Args: hide(bool): Hide or show the keyword argument boxes. """ + self._hide_kwarg_boxes = hide if len(self.kwarg_boxes) > 0: for box in self.kwarg_boxes: box.setVisible(not hide) @@ -300,7 +300,7 @@ def hide_kwarg_boxes(self, hide: bool): @SafeProperty(bool) def hide_scan_control_buttons(self): """Property to hide the scan control buttons.""" - return not self.button_run_scan.isVisible() + return self._hide_scan_control_buttons @hide_scan_control_buttons.setter def hide_scan_control_buttons(self, hide: bool): @@ -309,12 +309,13 @@ def hide_scan_control_buttons(self, hide: bool): Args: hide(bool): Hide or show the scan control buttons. """ + self._hide_scan_control_buttons = hide self.show_scan_control_buttons(not hide) @SafeProperty(bool) def hide_metadata(self): """Property to hide the metadata form.""" - return not self._metadata_form.isVisible() + return self._hide_metadata @hide_metadata.setter def hide_metadata(self, hide: bool): @@ -323,6 +324,7 @@ def hide_metadata(self, hide: bool): Args: hide(bool): Hide or show the metadata form. """ + self._hide_metadata = hide self._metadata_form.setVisible(not hide) @SafeProperty(bool) @@ -342,12 +344,13 @@ def hide_optional_metadata(self, hide: bool): @SafeSlot(bool) def show_scan_control_buttons(self, show: bool): """Shows or hides the scan control buttons.""" + self._hide_scan_control_buttons = not show self.scan_control_group.setVisible(show) @SafeProperty(bool) def hide_scan_selection_combobox(self): """Property to hide the scan selection combobox.""" - return not self.comboBox_scan_selection.isVisible() + return self._hide_scan_selection_combobox @hide_scan_selection_combobox.setter def hide_scan_selection_combobox(self, hide: bool): @@ -356,11 +359,13 @@ def hide_scan_selection_combobox(self, hide: bool): Args: hide(bool): Hide or show the scan selection combobox. """ + self._hide_scan_selection_combobox = hide self.show_scan_selection_combobox(not hide) @SafeSlot(bool) def show_scan_selection_combobox(self, show: bool): """Shows or hides the scan selection combobox.""" + self._hide_scan_selection_combobox = not show self.scan_selection_group.setVisible(show) @SafeSlot(str) @@ -415,6 +420,7 @@ def add_kwargs_boxes(self, groups: list): box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.layout.insertWidget(position + len(self.kwarg_boxes), box) self.kwarg_boxes.append(box) + box.setVisible(not self._hide_kwarg_boxes) def add_arg_group(self, group: dict): """ @@ -427,6 +433,7 @@ def add_arg_group(self, group: dict): self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box) + self.arg_box.setVisible(not self._hide_arg_box) @SafeSlot(str) def emit_device_selected(self, dev_names): diff --git a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py index 05a5623c4..68870b605 100644 --- a/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py +++ b/bec_widgets/widgets/dap/lmfit_dialog/lmfit_dialog.py @@ -65,6 +65,9 @@ def __init__( self._move_buttons = [] self._accent_colors = get_accent_colors() self.action_buttons = {} + self._hide_curve_selection = False + self._hide_summary = False + self._hide_parameters = False @property def enable_actions(self) -> bool: @@ -108,7 +111,7 @@ def always_show_latest(self, show: bool): @SafeProperty(bool) def hide_curve_selection(self): """SafeProperty for showing the curve selection.""" - return not self.ui.group_curve_selection.isVisible() + return self._hide_curve_selection @hide_curve_selection.setter def hide_curve_selection(self, show: bool): @@ -117,12 +120,13 @@ def hide_curve_selection(self, show: bool): Args: show (bool): Whether to show the curve selection. """ + self._hide_curve_selection = show self.ui.group_curve_selection.setVisible(not show) @SafeProperty(bool) def hide_summary(self) -> bool: """SafeProperty for showing the summary.""" - return not self.ui.group_summary.isVisible() + return self._hide_summary @hide_summary.setter def hide_summary(self, show: bool): @@ -131,12 +135,13 @@ def hide_summary(self, show: bool): Args: show (bool): Whether to show the summary. """ + self._hide_summary = show self.ui.group_summary.setVisible(not show) @SafeProperty(bool) def hide_parameters(self) -> bool: """SafeProperty for showing the parameters.""" - return not self.ui.group_parameters.isVisible() + return self._hide_parameters @hide_parameters.setter def hide_parameters(self, show: bool): @@ -145,6 +150,7 @@ def hide_parameters(self, show: bool): Args: show (bool): Whether to show the parameters. """ + self._hide_parameters = show self.ui.group_parameters.setVisible(not show) @property diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index d3c4be011..34d7b33bd 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -49,6 +49,7 @@ def __init__( self._scan_name = scan_name or "" self._md_schema = get_metadata_schema_for_scan(self._scan_name) self._additional_metadata.data_changed.connect(self.validate_form) + self._hide_optional_metadata = False super().__init__(parent=parent, data_model=self._md_schema, client=client, **kwargs) @@ -63,7 +64,7 @@ def update_with_new_scan(self, scan_name: str): @SafeProperty(bool) def hide_optional_metadata(self): # type: ignore """Property to hide the optional metadata table.""" - return not self._additional_md_box.isVisible() + return self._hide_optional_metadata @hide_optional_metadata.setter def hide_optional_metadata(self, hide: bool): @@ -72,6 +73,7 @@ def hide_optional_metadata(self, hide: bool): Args: hide(bool): Hide or show the optional metadata table. """ + self._hide_optional_metadata = hide self._additional_md_box.setVisible(not hide) def get_form_data(self): diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index f213aa261..13591634f 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -129,6 +129,12 @@ def __init__( self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item) self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item) + # Visibility States + self._toolbar_visible = True + self._enable_fps_monitor = False + self._outer_axes_visible = self.plot_item.getAxis("top").isVisible() + self._inner_axes_visible = self.plot_item.getAxis("bottom").isVisible() + self.toolbar = ModularToolBar(parent=self, orientation="horizontal") self._init_toolbar() @@ -294,7 +300,7 @@ def enable_toolbar(self) -> bool: """ Show Toolbar. """ - return self.toolbar.isVisible() + return self._toolbar_visible @enable_toolbar.setter def enable_toolbar(self, value: bool): @@ -304,6 +310,7 @@ def enable_toolbar(self, value: bool): Args: value(bool): The value to set. """ + self._toolbar_visible = value self.toolbar.setVisible(value) @SafeProperty(bool, doc="Enable the FPS monitor.") @@ -311,7 +318,7 @@ def enable_fps_monitor(self) -> bool: """ Enable the FPS monitor. """ - return self.fps_label.isVisible() + return self._enable_fps_monitor @enable_fps_monitor.setter def enable_fps_monitor(self, value: bool): @@ -321,9 +328,11 @@ def enable_fps_monitor(self, value: bool): Args: value(bool): The value to set. """ - if value and self.fps_monitor is None: + if value == self._enable_fps_monitor: + return + if value: self.hook_fps_monitor() - elif not value and self.fps_monitor is not None: + else: self.unhook_fps_monitor() ################################################################################ @@ -796,7 +805,7 @@ def outer_axes(self) -> bool: """ Show the outer axes of the plot widget. """ - return self.plot_item.getAxis("top").isVisible() + return self._outer_axes_visible @outer_axes.setter def outer_axes(self, value: bool): @@ -809,6 +818,7 @@ def outer_axes(self, value: bool): self.plot_item.showAxis("top", value) self.plot_item.showAxis("right", value) + self._outer_axes_visible = value self.property_changed.emit("outer_axes", value) @SafeProperty(bool, doc="Show inner axes of the plot widget.") @@ -816,7 +826,7 @@ def inner_axes(self) -> bool: """ Show inner axes of the plot widget. """ - return self.plot_item.getAxis("bottom").isVisible() + return self._inner_axes_visible @inner_axes.setter def inner_axes(self, value: bool): @@ -829,6 +839,7 @@ def inner_axes(self, value: bool): self.plot_item.showAxis("bottom", value) self.plot_item.showAxis("left", value) + self._inner_axes_visible = value self._apply_x_label() self._apply_y_label() self.property_changed.emit("inner_axes", value) @@ -969,6 +980,7 @@ def hook_fps_monitor(self): self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label) self.update_fps_label(0) + self._enable_fps_monitor = True def unhook_fps_monitor(self, delete_label=True): """Unhook the FPS monitor from the plot.""" @@ -980,6 +992,7 @@ def unhook_fps_monitor(self, delete_label=True): if self.fps_label is not None: # Hide Label self.fps_label.hide() + self._enable_fps_monitor = False ################################################################################ # Crosshair diff --git a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py index 401936e56..c8f01ac19 100644 --- a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py +++ b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py @@ -145,6 +145,9 @@ def __init__( self.layout.addWidget(self.ui) self.setLayout(self.layout) self.progressbar = self.ui.progressbar + self._show_elapsed_time = self.ui.elapsed_time_label.isVisible() + self._show_remaining_time = self.ui.remaining_time_label.isVisible() + self._show_source_label = self.ui.source_label.isVisible() self.connect_to_queue() self._progress_source = None @@ -221,30 +224,33 @@ def on_progress_update(self, msg_content: dict, metadata: dict): @SafeProperty(bool) def show_elapsed_time(self): - return self.ui.elapsed_time_label.isVisible() + return self._show_elapsed_time @show_elapsed_time.setter def show_elapsed_time(self, value): + self._show_elapsed_time = value self.ui.elapsed_time_label.setVisible(value) if hasattr(self.ui, "dash"): self.ui.dash.setVisible(value) @SafeProperty(bool) def show_remaining_time(self): - return self.ui.remaining_time_label.isVisible() + return self._show_remaining_time @show_remaining_time.setter def show_remaining_time(self, value): + self._show_remaining_time = value self.ui.remaining_time_label.setVisible(value) if hasattr(self.ui, "dash"): self.ui.dash.setVisible(value) @SafeProperty(bool) def show_source_label(self): - return self.ui.source_label.isVisible() + return self._show_source_label @show_source_label.setter def show_source_label(self, value): + self._show_source_label = value self.ui.source_label.setVisible(value) def update_labels(self): diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index 768a9c8d6..12f3730a5 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -51,6 +51,7 @@ def __init__( ) self.layout.setSpacing(0) self.layout.setContentsMargins(0, 0, 0, 0) + self._toolbar_hidden = False # Set up the toolbar self.set_toolbar() @@ -104,7 +105,7 @@ def set_toolbar(self): @Property(bool) def hide_toolbar(self): """Property to hide the BEC Queue toolbar.""" - return not self.toolbar.isVisible() + return self._toolbar_hidden @hide_toolbar.setter def hide_toolbar(self, hide: bool): @@ -123,6 +124,7 @@ def _hide_toolbar(self, hide: bool): Args: hide(bool): Whether to hide the toolbar. """ + self._toolbar_hidden = hide self.toolbar.setVisible(not hide) def refresh_queue(self): From fd2b8918f5483c8aa9052c1a8a17429d52b9540e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 11:32:27 +0100 Subject: [PATCH 144/161] feat(advanced_dock_area): instance lock for multiple ads in same session --- .../views/developer_view/developer_widget.py | 4 +- .../advanced_dock_area/advanced_dock_area.py | 76 +++++++++++++------ .../advanced_dock_area/profile_utils.py | 49 ++++++++++-- 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 38a6c856c..39c8f49ef 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -92,7 +92,7 @@ def __init__(self, parent=None, **kwargs): self.terminal = WebConsole(self, startup_cmd="") self.terminal.setObjectName("Terminal") self.monaco = MonacoDock(self) - self.monaco.setObjectName("Monaco Editor") + self.monaco.setObjectName("MonacoEditor") self.monaco.save_enabled.connect(self._on_save_enabled_update) self.plotting_ads = AdvancedDockArea( self, @@ -103,7 +103,7 @@ def __init__(self, parent=None, **kwargs): enable_profile_management=False, variant="compact", ) - self.plotting_ads.setObjectName("Plotting Area") + self.plotting_ads.setObjectName("PlottingArea") self.signature_help = QTextEdit(self) self.signature_help.setObjectName("Signature Help") self.signature_help.setAcceptRichText(True) 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 3bdadcf71..4cac5392c 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 @@ -118,6 +118,7 @@ def __init__( default_add_direction: Literal["left", "right", "top", "bottom"] = "right", profile_namespace: str | None = None, auto_profile_namespace: bool = True, + instance_id: str | None = None, auto_save_upon_exit: bool = True, enable_profile_management: bool = True, restore_initial_profile: bool = True, @@ -126,6 +127,7 @@ def __init__( self._profile_namespace_hint = profile_namespace self._profile_namespace_auto = auto_profile_namespace self._profile_namespace_resolved: str | None | object = _PROFILE_NAMESPACE_UNSET + self._instance_id = sanitize_namespace(instance_id) if instance_id else None self._auto_save_upon_exit = auto_save_upon_exit self._profile_management_enabled = enable_profile_management self._restore_initial_profile = restore_initial_profile @@ -181,24 +183,23 @@ def _fetch_initial_profile(self): # Restore last-used profile if available; otherwise fall back to combo selection combo = self.toolbar.components.get_action("workspace_combo").widget namespace = self.profile_namespace - last = get_last_profile(namespace) - if last: - user_exists = any( - os.path.exists(path) for path in user_profile_candidates(last, namespace) + init_profile = None + instance_id = self._last_profile_instance_id() + if instance_id: + inst_profile = get_last_profile( + namespace=namespace, instance=instance_id, allow_namespace_fallback=False ) - default_exists = any( - os.path.exists(path) for path in default_profile_candidates(last, namespace) - ) - init_profile = last if (user_exists or default_exists) else None - else: - init_profile = combo.currentText() + if inst_profile and self._profile_exists(inst_profile, namespace): + init_profile = inst_profile if not init_profile: - general_exists = any( - os.path.exists(path) for path in user_profile_candidates("general", namespace) - ) or any( - os.path.exists(path) for path in default_profile_candidates("general", namespace) - ) - if general_exists: + last = get_last_profile(namespace=namespace) + if last and self._profile_exists(last, namespace): + init_profile = last + else: + text = combo.currentText() + init_profile = text if text else None + if not init_profile: + if self._profile_exists("general", namespace): init_profile = "general" if init_profile: # Defer initial load to the event loop so child widgets exist before state restore. @@ -500,6 +501,14 @@ def lock_workspace(self, value: bool): for dock in self.dock_list(): dock.setting_action.setVisible(not value) + def _last_profile_instance_id(self) -> str | None: + """ + Identifier used to scope the last-profile entry for this dock area. + + When unset, profiles are scoped only by namespace. + """ + return self._instance_id + def _resolve_profile_namespace(self) -> str | None: if self._profile_namespace_resolved is not _PROFILE_NAMESPACE_UNSET: return self._profile_namespace_resolved # type: ignore[return-value] @@ -536,6 +545,11 @@ def _active_profile_name_or_default(self) -> str: self._current_profile_name = name return name + def _profile_exists(self, name: str, namespace: str | None) -> bool: + return any( + os.path.exists(path) for path in user_profile_candidates(name, namespace) + ) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace)) + def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.save_state(settings=settings) @@ -630,7 +644,7 @@ def _profile_exists(profile_name: str) -> bool: workspace_combo.setCurrentText(name) self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -688,7 +702,7 @@ def load_profile(self, name: str | None = None): self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -889,7 +903,7 @@ def prepare_for_shutdown(self) -> None: namespace = self.profile_namespace settings = open_user_settings(name, namespace=namespace) self._write_snapshot_to_settings(settings) - set_last_profile(name, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) self._exit_snapshot_written = True def cleanup(self): @@ -910,13 +924,31 @@ def cleanup(self): if __name__ == "__main__": # pragma: no cover import sys + from qtpy.QtWidgets import QTabWidget + app = QApplication(sys.argv) apply_theme("dark") dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() - ads = AdvancedDockArea(mode="creator", root_widget=True, enable_profile_management=True) - window.setCentralWidget(ads) + central = QWidget() + layout = QVBoxLayout(central) + window.setCentralWidget(central) + + # two dock areas stacked vertically no instance ids + ads = AdvancedDockArea(mode="creator", enable_profile_management=True) + ads2 = AdvancedDockArea(mode="creator", enable_profile_management=True) + layout.addWidget(ads, 1) + layout.addWidget(ads2, 1) + + # two dock areas inside a tab widget + tabs = QTabWidget(parent=central) + ads3 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab3") + ads4 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab4") + tabs.addTab(ads3, "Workspace 3") + tabs.addTab(ads4, "Workspace 4") + layout.addWidget(tabs, 1) + window.show() - window.resize(800, 600) + window.resize(800, 1000) sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 9e239b033..f09f5c544 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -559,9 +559,10 @@ def _app_settings() -> QSettings: return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat) -def _last_profile_key(namespace: str | None) -> str: +def _last_profile_key(namespace: str | None, instance: str | None = None) -> str: """ - Build the QSettings key used to store the last profile per namespace. + Build the QSettings key used to store the last profile per namespace and + optional instance id. Args: namespace (str | None): Namespace label. @@ -571,37 +572,69 @@ def _last_profile_key(namespace: str | None) -> str: """ ns = sanitize_namespace(namespace) key = SETTINGS_KEYS["last_profile"] - return f"{key}/{ns}" if ns else key + if ns: + key = f"{key}/{ns}" + inst = sanitize_namespace(instance) if instance else "" + if inst: + key = f"{key}@{inst}" + return key -def get_last_profile(namespace: str | None = None) -> str | None: +def get_last_profile( + namespace: str | None = None, + instance: str | None = None, + *, + allow_namespace_fallback: bool = True, +) -> str | None: """ Retrieve the last-used profile name persisted in app settings. + When *instance* is provided, the lookup is scoped to that particular dock + area instance. If the instance-specific entry is missing and + ``allow_namespace_fallback`` is True, the namespace-wide entry is + consulted next. + Args: namespace (str | None, optional): Namespace label. Defaults to ``None``. + instance (str | None, optional): Optional instance ID. Defaults to ``None``. + allow_namespace_fallback (bool): Whether to fall back to the namespace + entry when an instance-specific value is not found. Defaults to ``True``. Returns: str | None: Profile name or ``None`` if none has been stored. """ s = _app_settings() - name = s.value(_last_profile_key(namespace), "", type=str) + inst = instance or None + if inst: + name = s.value(_last_profile_key(namespace, inst), "", type=str) + if name: + return name + if not allow_namespace_fallback: + return None + name = s.value(_last_profile_key(namespace, None), "", type=str) return name or None -def set_last_profile(name: str | None, namespace: str | None = None) -> None: +def set_last_profile( + name: str | None, namespace: str | None = None, instance: str | None = None +) -> None: """ Persist the last-used profile name (or clear the value when ``None``). + When *instance* is provided, the value is stored under a key specific to + that dock area instance; otherwise it is stored under the namespace-wide key. + Args: name (str | None): Profile name to store. namespace (str | None, optional): Namespace label. Defaults to ``None``. + instance (str | None, optional): Optional instance ID. Defaults to ``None``. """ s = _app_settings() + key = _last_profile_key(namespace, instance) if name: - s.setValue(_last_profile_key(namespace), name) + s.setValue(key, name) else: - s.remove(_last_profile_key(namespace)) + s.remove(key) def now_iso_utc() -> str: From dc30e6b022e5db66d945261d15fb88b3220d9252 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 21:58:37 +0100 Subject: [PATCH 145/161] fix(widget_state_manager): visibility managed by parent --- bec_widgets/utils/widget_state_manager.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 78066773a..a06d60164 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -109,9 +109,11 @@ def _save_widget_state_qsettings( prop = meta.property(i) name = prop.name() - # Always save `visible` as True to avoid restoring hidden widgets from profiles. + # Skip persisting QWidget visibility because container widgets (e.g. tab + # stacks, dock managers) manage that state themselves. Restoring a saved + # False can permanently hide a widget, while forcing True makes hidden + # tabs show on top. Leave the property to the parent widget instead. if name == "visible": - settings.setValue(name, True) continue if ( @@ -170,6 +172,8 @@ def _load_widget_state_qsettings( for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() + if name == "visible": + continue if settings.contains(name): value = settings.value(name) widget.setProperty(name, value) From 78485d3df03104ff698b7e27a206ba84d6e88ae2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 21 Nov 2025 12:07:53 +0100 Subject: [PATCH 146/161] fix(widget_state_manager): omits QIcon properties to prevent segfault --- bec_widgets/utils/widget_state_manager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index a06d60164..ae5632b86 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -3,6 +3,7 @@ import shiboken6 from bec_lib import bec_logger from qtpy.QtCore import QSettings +from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -20,7 +21,7 @@ logger = bec_logger.logger -PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText"] +PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText", "locale", "styleSheet"] class WidgetStateManager: @@ -126,7 +127,10 @@ def _save_widget_state_qsettings( continue value = widget.property(name) + if isinstance(value, QIcon): + continue settings.setValue(name, value) + settings.endGroup() # Recursively process children (only if they aren't skipped) From f9c03f1daa55a87d7709f5417655ed565db09bfe Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sun, 23 Nov 2025 13:28:20 +0100 Subject: [PATCH 147/161] refactor: improvements to enum access --- .../advanced_dock_area/advanced_dock_area.py | 13 +++++++------ .../toolbar_components/workspace_actions.py | 18 ++++++++++-------- .../explorer/collapsible_tree_section.py | 5 ++--- .../containers/main_window/main_window.py | 19 ++++++------------- .../positioner_box_2d/positioner_box_2d.py | 5 ++--- .../control/scan_control/scan_control.py | 14 +++++++++----- .../widgets/services/bec_queue/bec_queue.py | 2 +- 7 files changed, 37 insertions(+), 39 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 4cac5392c..83b88a7e2 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 @@ -596,7 +596,7 @@ def _profile_exists(profile_name: str) -> bool: origin_label=lambda n: profile_origin_display(n, namespace=namespace), quick_select_checked=quickselect_default, ) - if dialog.exec() != QDialog.Accepted: + if dialog.exec() != QDialog.DialogCode.Accepted: return name = dialog.get_profile_name() @@ -760,10 +760,10 @@ def delete_profile(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, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if reply != QMessageBox.Yes: + if reply != QMessageBox.StandardButton.Yes: return namespace = self.profile_namespace @@ -833,8 +833,9 @@ def show_workspace_manager(self): def _manage_dialog_closed(self): self.manage_widget.close() self.manage_widget.deleteLater() - self.manage_dialog.deleteLater() - self.manage_dialog = None + if self.manage_dialog is not None: + self.manage_dialog.deleteLater() + self.manage_dialog = None self.toolbar.components.get_action("manage_workspaces").action.setChecked(False) ################################################################################ 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 cb7dabea1..58bb8cbe9 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 @@ -18,7 +18,7 @@ class ProfileComboBox(QComboBox): def __init__(self, parent=None): super().__init__(parent) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._quick_provider: Callable[[], list[str]] = list_quick_profiles def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None: @@ -48,9 +48,9 @@ def refresh_profiles(self, active_profile: str | None = None): idx = self.count() - 1 # Reset any custom styling - self.setItemData(idx, None, Qt.FontRole) - self.setItemData(idx, None, Qt.ToolTipRole) - self.setItemData(idx, None, Qt.ForegroundRole) + self.setItemData(idx, None, Qt.ItemDataRole.FontRole) + self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole) + self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole) if active_profile and profile == active_profile: tooltip = "Active workspace profile" @@ -58,13 +58,15 @@ def refresh_profiles(self, active_profile: str | None = None): font = QFont(self.font()) font.setItalic(True) font.setBold(True) - self.setItemData(idx, font, Qt.FontRole) - self.setItemData(idx, self.palette().highlight().color(), Qt.ForegroundRole) + self.setItemData(idx, font, Qt.ItemDataRole.FontRole) + self.setItemData( + idx, self.palette().highlight().color(), Qt.ItemDataRole.ForegroundRole + ) tooltip = "Active profile (not in quick select)" - self.setItemData(idx, tooltip, Qt.ToolTipRole) + self.setItemData(idx, tooltip, Qt.ItemDataRole.ToolTipRole) self.setCurrentIndex(idx) elif profile not in quick_set: - self.setItemData(idx, "Not in quick select", Qt.ToolTipRole) + self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole) # Restore selection if possible index = self.findText(current_text) diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index 3c9d9863f..d2c3ebe13 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -72,7 +72,7 @@ def __init__( self.header_add_button.setFixedSize(28, 28) self.header_add_button.setToolTip("Add item") self.header_add_button.setVisible(show_add_button) - self.header_add_button.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.header_add_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.header_add_button.setAutoRaise(True) self.header_add_button.setIcon(material_icon("add", size=(28, 28), convert_to_pixmap=False)) @@ -107,10 +107,9 @@ def _update_appearance(self): # Get theme colors palette = get_theme_palette() - text_color = palette.text().color().name() self.header_button.setStyleSheet( - f""" + """ QPushButton {{ font-weight: bold; text-align: left; diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 019874ea2..2fc59d0e7 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from typing import TYPE_CHECKING from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer @@ -34,7 +35,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__) # Ensure the application does not use the native menu bar on macOS to be consistent with linux development. -QApplication.setAttribute(Qt.AA_DontUseNativeMenuBar, True) +QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, True) class BECMainWindow(BECWidget, QMainWindow): @@ -43,16 +44,8 @@ class BECMainWindow(BECWidget, QMainWindow): SCAN_PROGRESS_WIDTH = 100 # px STATUS_BAR_WIDGETS_EXPIRE_TIME = 60_000 # milliseconds - def __init__( - self, - parent=None, - gui_id: str = None, - client=None, - window_title: str = "BEC", - *args, - **kwargs, - ): - super().__init__(parent=parent, gui_id=gui_id, **kwargs) + def __init__(self, parent=None, window_title: str = "BEC", **kwargs): + super().__init__(parent=parent, **kwargs) self.app = QApplication.instance() self.status_bar = self.statusBar() @@ -384,8 +377,8 @@ def _setup_menu_bar(self): # Help menu help_menu = menu_bar.addMenu("Help") - help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion) - bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation) + help_icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxQuestion) + bug_icon = QApplication.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation) bec_docs = QAction("BEC Docs", self) bec_docs.setIcon(help_icon) 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 819af79c9..c3057216f 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 @@ -319,7 +319,7 @@ def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents "tweak_decrease": self.ui.tweak_decrease_hor, "units": self.ui.units_hor, } - elif device == "vertical": + if device == "vertical": return { "spinner": self.ui.spinner_widget_ver, "position_indicator": self.ui.position_indicator_ver, @@ -332,8 +332,7 @@ def _device_ui_components_hv(self, device: DeviceId) -> DeviceUpdateUIComponents "tweak_decrease": self.ui.tweak_decrease_ver, "units": self.ui.units_ver, } - else: - raise ValueError(f"Device {device} is not represented by this UI") + raise ValueError(f"Device {device} is not represented by this UI") def _device_ui_components(self, device: str): if device == self.device_hor: diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index f1ef4b2b7..1a633ec3a 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -125,7 +125,7 @@ def _init_UI(self): # Label to reload the last scan parameters within scan selection group box self.toggle_layout = QHBoxLayout() self.toggle_layout.addSpacerItem( - QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed) + QSpacerItem(0, 0, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) ) self.last_scan_label = QLabel("Restore last scan parameters", self.scan_selection_group) self.toggle = ToggleSwitch(parent=self.scan_selection_group, checked=False) @@ -133,12 +133,16 @@ def _init_UI(self): self.toggle_layout.addWidget(self.last_scan_label) self.toggle_layout.addWidget(self.toggle) self.scan_selection_group.layout().addLayout(self.toggle_layout) - self.scan_selection_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.scan_selection_group.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed + ) self.layout.addWidget(self.scan_selection_group) # Scan control (Run/Stop) buttons self.scan_control_group = QWidget(self) - self.scan_control_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.scan_control_group.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed + ) self.button_layout = QHBoxLayout(self.scan_control_group) self.button_run_scan = QPushButton("Start", self.scan_control_group) self.button_run_scan.setProperty("variant", "success") @@ -417,7 +421,7 @@ def add_kwargs_boxes(self, groups: list): position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0) for group in groups: box = ScanGroupBox(box_type="kwargs", config=group) - box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.layout.insertWidget(position + len(self.kwarg_boxes), box) self.kwarg_boxes.append(box) box.setVisible(not self._hide_kwarg_boxes) @@ -430,7 +434,7 @@ def add_arg_group(self, group: dict): """ self.arg_box = ScanGroupBox(box_type="args", config=group) self.arg_box.device_selected.connect(self.emit_device_selected) - self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.arg_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box) self.arg_box.setVisible(not self._hide_arg_box) diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index 12f3730a5..d7d095152 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -199,7 +199,7 @@ def format_item(self, content: str, status=False) -> QTableWidgetItem: if not content or not isinstance(content, str): content = "" item = QTableWidgetItem(content) - item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter) # item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) if status: From d090f8f7e54519f6fe7b460ca6a8e73d1643ff7b Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sun, 23 Nov 2025 13:28:44 +0100 Subject: [PATCH 148/161] refactor(developer widget): type hint improvements --- .../views/developer_view/developer_widget.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 39c8f49ef..ce7030c3a 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -14,6 +14,7 @@ from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget from bec_widgets.widgets.editors.web_console.web_console import WebConsole from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer @@ -116,6 +117,7 @@ def __init__(self, parent=None, **kwargs): lambda text: self.signature_help.setHtml(markdown_to_html(text)) ) self._current_script_id: str | None = None + self.script_editor_tab = None self._initialize_layout() @@ -295,12 +297,14 @@ def _on_save_enabled_update(self, enabled: bool): @SafeSlot() def on_execute(self): + """Upload and run the currently focused script in the Monaco editor.""" self.script_editor_tab = self.monaco.last_focused_editor if not self.script_editor_tab: return - self.current_script_id = upload_script( - self.client.connector, self.script_editor_tab.widget().get_text() - ) + widget = self.script_editor_tab.widget() + if not isinstance(widget, MonacoWidget): + return + self.current_script_id = upload_script(self.client.connector, widget.get_text()) self.console.write(f'bec._run_script("{self.current_script_id}")') print(f"Uploaded script with ID: {self.current_script_id}") @@ -334,14 +338,25 @@ def _update_subscription(self, new_script_id: str | None, old_script_id: str | N @SafeSlot(dict, dict) def on_script_execution_info(self, content: dict, metadata: dict): + """ + Handle script execution info messages to update the editor highlights. + Args: + content (dict): The content of the message containing execution info. + metadata (dict): Additional metadata for the message. + """ print(f"Script execution info: {content}") current_lines = content.get("current_lines") + if self.script_editor_tab is None: + return + widget = self.script_editor_tab.widget() + if not isinstance(widget, MonacoWidget): + return if not current_lines: - self.script_editor_tab.widget().clear_highlighted_lines() + widget.clear_highlighted_lines() return line_number = current_lines[0] - self.script_editor_tab.widget().clear_highlighted_lines() - self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) + widget.clear_highlighted_lines() + widget.set_highlighted_lines(line_number, line_number) def cleanup(self): self.delete_all() From c2780d629ca0bc2cfb3fbdbe31784320240f376f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 21 Nov 2025 13:47:28 +0100 Subject: [PATCH 149/161] feat(advanced_dock_area): floating docks restore with relative geometry --- bec_widgets/cli/client.py | 4 + .../advanced_dock_area/advanced_dock_area.py | 16 ++ .../advanced_dock_area/basic_dock_area.py | 158 +++++++++++++++++- .../advanced_dock_area/profile_utils.py | 87 +++++++++- tests/unit_tests/test_advanced_dock_area.py | 79 +++++++++ 5 files changed, 339 insertions(+), 5 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index c8339776e..2052c9b90 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -1352,6 +1352,7 @@ def new( floatable: "bool" = True, movable: "bool" = True, start_floating: "bool" = False, + floating_state: "Mapping[str, object] | None" = None, where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, tab_with: "CDockWidget | QWidget | str | None" = None, @@ -1374,6 +1375,7 @@ def new( floatable(bool): Whether the dock is floatable. movable(bool): Whether the dock is movable. start_floating(bool): Whether to start the dock floating. + floating_state(Mapping | None): Optional floating geometry metadata to apply when floating. where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when ``relative_to`` is provided without an explicit value). on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). @@ -2901,6 +2903,7 @@ def new( floatable: "bool" = True, movable: "bool" = True, start_floating: "bool" = False, + floating_state: "Mapping[str, object] | None" = None, where: "Literal['left', 'right', 'top', 'bottom'] | None" = None, on_close: "Callable[[CDockWidget, QWidget], None] | None" = None, tab_with: "CDockWidget | QWidget | str | None" = None, @@ -2923,6 +2926,7 @@ def new( floatable(bool): Whether the dock is floatable. movable(bool): Whether the dock is movable. start_floating(bool): Whether to start the dock floating. + floating_state(Mapping | None): Optional floating geometry metadata to apply when floating. where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when ``relative_to`` is provided without an explicit value). on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). 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 83b88a7e2..07e878ee6 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 @@ -551,6 +551,13 @@ def _profile_exists(self, name: str, namespace: str | None) -> bool: ) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace)) def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: + """ + Write the current workspace snapshot to the provided settings object. + + Args: + settings(QSettings): The settings object to write to. + save_preview(bool): Whether to save a screenshot preview. + """ self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.save_state(settings=settings) write_manifest(settings, self.dock_list()) @@ -688,11 +695,20 @@ def load_profile(self, name: str | None = None): if obj_name not in self.widget_map(): w = widget_handler.create_widget(widget_type=widget_class, parent=self) w.setObjectName(obj_name) + floating_state = None + if item.get("floating"): + floating_state = { + "relative": item.get("floating_relative"), + "absolute": item.get("floating_absolute"), + "screen_name": item.get("floating_screen"), + } self._make_dock( w, closable=item["closable"], floatable=item["floatable"], movable=item["movable"], + start_floating=item.get("floating", False), + floating_state=floating_state, area=QtAds.DockWidgetArea.RightDockWidgetArea, ) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py index 7334bd85c..5620e7d1e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -4,10 +4,11 @@ from dataclasses import dataclass from typing import Any, Callable, Literal, Mapping, Sequence, cast +from bec_lib import bec_logger from bec_qthemes import material_icon from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer from qtpy.QtGui import QIcon -from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget +from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget from shiboken6 import isValid import bec_widgets.widgets.containers.qt_ads as QtAds @@ -22,6 +23,8 @@ CDockWidget, ) +logger = bec_logger.logger + class DockSettingsDialog(QDialog): """Generic settings editor shown from dock title bar actions.""" @@ -64,6 +67,7 @@ class DockCreationSpec: floatable: bool = True movable: bool = True start_floating: bool = False + floating_state: Mapping[str, Any] | None = None area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea on_close: Callable[[CDockWidget, QWidget], None] | None = None tab_with: CDockWidget | None = None @@ -258,6 +262,7 @@ def _make_dock( movable: bool = True, area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea, start_floating: bool = False, + floating_state: Mapping[str, object] | None = None, on_close: Callable[[CDockWidget, QWidget], None] | None = None, tab_with: CDockWidget | None = None, relative_to: CDockWidget | None = None, @@ -276,6 +281,7 @@ def _make_dock( movable(bool): Whether the dock can be moved. area(QtAds.DockWidgetArea): Target dock area. start_floating(bool): Whether the dock should start floating. + floating_state(Mapping | None): Optional geometry metadata to apply when floating. on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. tab_with(CDockWidget | None): Optional dock to tab with. relative_to(CDockWidget | None): Optional dock to position relative to. @@ -336,6 +342,8 @@ def on_widget_destroyed(): if start_floating and tab_with is None and not promote_central: dock.setFloating() + if floating_state: + self._apply_floating_state_to_dock(dock, floating_state) if resolved_icon is not None: dock.setIcon(resolved_icon) return dock @@ -424,6 +432,7 @@ def _build_creation_spec( floatable: bool, movable: bool, start_floating: bool, + floating_state: Mapping[str, object] | None, where: Literal["left", "right", "top", "bottom"] | None, on_close: Callable[[CDockWidget, QWidget], None] | None, tab_with: CDockWidget | QWidget | str | None, @@ -444,6 +453,7 @@ def _build_creation_spec( floatable(bool): Whether the dock can be floated. movable(bool): Whether the dock can be moved. start_floating(bool): Whether the dock should start floating. + floating_state(Mapping | None): Optional floating geometry metadata. where(Literal["left", "right", "top", "bottom"] | None): Target dock area. on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. tab_with(CDockWidget | QWidget | str | None): Optional dock to tab with. @@ -489,6 +499,7 @@ def _build_creation_spec( floatable=floatable, movable=movable, start_floating=start_floating, + floating_state=floating_state, area=target_area, on_close=on_close, tab_with=resolved_tab, @@ -517,6 +528,7 @@ def _create_dock_from_spec(self, spec: DockCreationSpec) -> CDockWidget: closable=spec.closable, floatable=spec.floatable, movable=spec.movable, + floating_state=spec.floating_state, area=spec.area, start_floating=spec.start_floating, on_close=spec.on_close, @@ -824,6 +836,126 @@ def _settings_keys(overrides: Mapping[str, str | None] | None = None) -> dict[st defaults[key] = value return defaults + def _select_screen_for_entry( + self, entry: Mapping[str, object], container: QtAds.CFloatingDockContainer | None + ): + """ + Pick the best target screen for a saved floating container. + + Args: + entry(Mapping[str, object]): Floating window entry. + container(QtAds.CFloatingDockContainer | None): Optional container instance. + """ + screens = QApplication.screens() or [] + try: + name = entry.get("screen_name") or "" + except Exception as exc: + logger.warning(f"Invalid screen_name in floating window entry: {exc}") + name = "" + if name: + for screen in screens: + try: + if screen.name() == name: + return screen + except Exception as exc: + logger.warning(f"Error checking screen name '{name}': {exc}") + continue + if container is not None and hasattr(container, "screen"): + screen = container.screen() + if screen is not None: + return screen + return screens[0] if screens else None + + def _apply_saved_floating_geometry( + self, container: QtAds.CFloatingDockContainer, entry: Mapping[str, object] + ) -> None: + """ + Resize/move a floating container using saved geometry information. + + Args: + container(QtAds.CFloatingDockContainer): Target floating container. + entry(Mapping[str, object]): Floating window entry. + """ + abs_geom = entry.get("absolute") if isinstance(entry, Mapping) else None + if isinstance(abs_geom, Mapping): + try: + x = int(abs_geom.get("x")) + y = int(abs_geom.get("y")) + width = int(abs_geom.get("w")) + height = int(abs_geom.get("h")) + except Exception as exc: + logger.warning(f"Invalid absolute geometry in floating window entry: {exc}") + else: + if width > 0 and height > 0: + container.setGeometry(x, y, max(width, 50), max(height, 50)) + return + + rel = entry.get("relative") if isinstance(entry, Mapping) else None + if not isinstance(rel, Mapping): + return + try: + x_ratio = float(rel.get("x")) + y_ratio = float(rel.get("y")) + w_ratio = float(rel.get("w")) + h_ratio = float(rel.get("h")) + except Exception as exc: + logger.warning(f"Invalid relative geometry in floating window entry: {exc}") + return + + screen = self._select_screen_for_entry(entry, container) + if screen is None: + return + geom = screen.availableGeometry() + screen_w = geom.width() + screen_h = geom.height() + if screen_w <= 0 or screen_h <= 0: + return + + min_w = 120 + min_h = 80 + width = max(min_w, int(round(screen_w * max(w_ratio, 0.05)))) + height = max(min_h, int(round(screen_h * max(h_ratio, 0.05)))) + width = min(width, screen_w) + height = min(height, screen_h) + + x = geom.left() + int(round(screen_w * x_ratio)) + y = geom.top() + int(round(screen_h * y_ratio)) + x = max(geom.left(), min(x, geom.left() + screen_w - width)) + y = max(geom.top(), min(y, geom.top() + screen_h - height)) + + container.setGeometry(x, y, width, height) + + def _apply_floating_state_to_dock( + self, dock: CDockWidget, state: Mapping[str, object], *, attempt: int = 0 + ) -> None: + """ + Apply saved floating geometry to a dock once its container exists. + + Args: + dock(CDockWidget): Target dock widget. + state(Mapping[str, object]): Saved floating state. + attempt(int): Current attempt count for retries. + """ + if state is None: + return + + def schedule(next_attempt: int): + QTimer.singleShot( + 50, lambda: self._apply_floating_state_to_dock(dock, state, attempt=next_attempt) + ) + + container = dock.floatingDockContainer() + if container is None: + if attempt < 10: + schedule(attempt + 1) + return + entry = { + "relative": state.get("relative") if isinstance(state, Mapping) else None, + "absolute": state.get("absolute") if isinstance(state, Mapping) else None, + "screen_name": state.get("screen_name") if isinstance(state, Mapping) else None, + } + self._apply_saved_floating_geometry(container, entry) + def save_to_settings( self, settings: QSettings, @@ -1083,6 +1215,7 @@ def new( floatable: bool = True, movable: bool = True, start_floating: bool = False, + floating_state: Mapping[str, object] | None = None, where: Literal["left", "right", "top", "bottom"] | None = None, on_close: Callable[[CDockWidget, QWidget], None] | None = None, tab_with: CDockWidget | QWidget | str | None = None, @@ -1105,6 +1238,7 @@ def new( floatable(bool): Whether the dock is floatable. movable(bool): Whether the dock is movable. start_floating(bool): Whether to start the dock floating. + floating_state(Mapping | None): Optional floating geometry metadata to apply when floating. where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when ``relative_to`` is provided without an explicit value). on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). @@ -1148,6 +1282,7 @@ def new( floatable=floatable, movable=movable, start_floating=start_floating, + floating_state=floating_state, where=where, on_close=on_close, tab_with=tab_with, @@ -1173,6 +1308,7 @@ def _on_name_established(_name: str) -> None: floatable=floatable, movable=movable, start_floating=start_floating, + floating_state=floating_state, where=where, on_close=on_close, tab_with=tab_with, @@ -1187,13 +1323,29 @@ def _on_name_established(_name: str) -> None: dock = self._create_dock_from_spec(spec) return dock if return_dock else widget + def _iter_all_docks(self) -> list[CDockWidget]: + """Return all docks, including those hosted in floating containers.""" + docks = list(self.dock_manager.dockWidgets()) + seen = {id(d) for d in docks} + for container in self.dock_manager.floatingWidgets(): + if container is None: + continue + for dock in container.dockWidgets(): + if dock is None: + continue + if id(dock) in seen: + continue + docks.append(dock) + seen.add(id(dock)) + return docks + def dock_map(self) -> dict[str, CDockWidget]: """Return the dock widgets map as dictionary with names as keys.""" - return self.dock_manager.dockWidgetsMap() + return {dock.objectName(): dock for dock in self._iter_all_docks() if dock.objectName()} def dock_list(self) -> list[CDockWidget]: """Return the list of dock widgets.""" - return self.dock_manager.dockWidgets() + return self._iter_all_docks() def widget_map(self) -> dict[str, QWidget]: """Return a dictionary mapping widget names to their corresponding widgets.""" diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index f09f5c544..87f039686 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -22,6 +22,7 @@ from pydantic import BaseModel, Field from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import QApplication from bec_widgets.widgets.containers.qt_ads import CDockWidget @@ -655,8 +656,44 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: settings(QSettings): Settings object to write to. docks(list[CDockWidget]): List of dock widgets to serialize. """ - settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks)) - for i, dock in enumerate(docks): + + def _floating_snapshot(dock: CDockWidget) -> dict | None: + if not hasattr(dock, "isFloating") or not dock.isFloating(): + return None + container = dock.floatingDockContainer() if hasattr(dock, "floatingDockContainer") else None + if container is None: + return None + geom = container.frameGeometry() + if geom.isNull(): + return None + absolute = {"x": geom.x(), "y": geom.y(), "w": geom.width(), "h": geom.height()} + screen = container.screen() if hasattr(container, "screen") else None + if screen is None: + screen = QApplication.screenAt(geom.center()) if QApplication.instance() else None + screen_name = "" + relative = None + if screen is not None: + if hasattr(screen, "name"): + try: + screen_name = screen.name() + except Exception: + screen_name = "" + avail = screen.availableGeometry() + width = max(1, avail.width()) + height = max(1, avail.height()) + relative = { + "x": (geom.left() - avail.left()) / float(width), + "y": (geom.top() - avail.top()) / float(height), + "w": geom.width() / float(width), + "h": geom.height() / float(height), + } + return {"screen_name": screen_name, "relative": relative, "absolute": absolute} + + ordered_docks = [dock for dock in docks if dock.isFloating()] + [ + dock for dock in docks if not dock.isFloating() + ] + settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(ordered_docks)) + for i, dock in enumerate(ordered_docks): settings.setArrayIndex(i) w = dock.widget() settings.setValue("object_name", w.objectName()) @@ -664,6 +701,32 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: settings.setValue("closable", getattr(dock, "_default_closable", True)) settings.setValue("floatable", getattr(dock, "_default_floatable", True)) settings.setValue("movable", getattr(dock, "_default_movable", True)) + is_floating = bool(dock.isFloating()) + settings.setValue("floating", is_floating) + if is_floating: + snapshot = _floating_snapshot(dock) + if snapshot: + relative = snapshot.get("relative") or {} + absolute = snapshot.get("absolute") or {} + settings.setValue("floating_screen", snapshot.get("screen_name", "")) + settings.setValue("floating_rel_x", relative.get("x", 0.0)) + settings.setValue("floating_rel_y", relative.get("y", 0.0)) + settings.setValue("floating_rel_w", relative.get("w", 0.0)) + settings.setValue("floating_rel_h", relative.get("h", 0.0)) + settings.setValue("floating_abs_x", absolute.get("x", 0)) + settings.setValue("floating_abs_y", absolute.get("y", 0)) + settings.setValue("floating_abs_w", absolute.get("w", 0)) + settings.setValue("floating_abs_h", absolute.get("h", 0)) + else: + settings.setValue("floating_screen", "") + settings.setValue("floating_rel_x", 0.0) + settings.setValue("floating_rel_y", 0.0) + settings.setValue("floating_rel_w", 0.0) + settings.setValue("floating_rel_h", 0.0) + settings.setValue("floating_abs_x", 0) + settings.setValue("floating_abs_y", 0) + settings.setValue("floating_abs_w", 0) + settings.setValue("floating_abs_h", 0) settings.endArray() @@ -681,6 +744,22 @@ def read_manifest(settings: QSettings) -> list[dict]: count = settings.beginReadArray(SETTINGS_KEYS["manifest"]) for i in range(count): settings.setArrayIndex(i) + floating = settings.value("floating", False, type=bool) + rel = { + "x": float(settings.value("floating_rel_x", 0.0)), + "y": float(settings.value("floating_rel_y", 0.0)), + "w": float(settings.value("floating_rel_w", 0.0)), + "h": float(settings.value("floating_rel_h", 0.0)), + } + abs_geom = { + "x": int(settings.value("floating_abs_x", 0)), + "y": int(settings.value("floating_abs_y", 0)), + "w": int(settings.value("floating_abs_w", 0)), + "h": int(settings.value("floating_abs_h", 0)), + } + if not floating: + rel = None + abs_geom = None items.append( { "object_name": settings.value("object_name"), @@ -688,6 +767,10 @@ def read_manifest(settings: QSettings) -> list[dict]: "closable": settings.value("closable", type=bool), "floatable": settings.value("floatable", type=bool), "movable": settings.value("movable", type=bool), + "floating": floating, + "floating_screen": settings.value("floating_screen", ""), + "floating_relative": rel, + "floating_absolute": abs_geom, } ) settings.endArray() diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 212494b2f..f108d39b5 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -21,6 +21,7 @@ DockSettingsDialog, ) from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + SETTINGS_KEYS, default_profile_path, get_profile_info, is_profile_read_only, @@ -249,6 +250,31 @@ def test_attach_all_and_delete_all(self, basic_dock_area): basic_dock_area.delete_all() assert basic_dock_area.dock_list() == [] + def test_manifest_serialization_includes_floating_geometry( + self, basic_dock_area, qtbot, tmp_path + ): + anchored = QWidget(parent=basic_dock_area) + anchored.setObjectName("anchored_widget") + floating = QWidget(parent=basic_dock_area) + floating.setObjectName("floating_widget") + + basic_dock_area.new(anchored, return_dock=True) + dock_floating = basic_dock_area.new(floating, return_dock=True, start_floating=True) + qtbot.waitUntil(lambda: dock_floating.isFloating(), timeout=2000) + + settings_path = tmp_path / "manifest.ini" + settings = QSettings(str(settings_path), QSettings.IniFormat) + write_manifest(settings, basic_dock_area.dock_list()) + settings.sync() + + manifest_entries = read_manifest(settings) + assert len(manifest_entries) == 2 + assert manifest_entries[0]["object_name"] == "floating_widget" + assert manifest_entries[0]["floating"] is True + assert manifest_entries[0]["floating_relative"] is not None + assert manifest_entries[1]["object_name"] == "anchored_widget" + assert manifest_entries[1]["floating"] is False + def test_splitter_weight_coercion_supports_aliases(self, basic_dock_area): weights = {"default": 0.5, "left": 2, "center": 3, "right": 4} @@ -837,6 +863,59 @@ def test_attach_all_action(self, advanced_dock_area, qtbot): final_floating = len(advanced_dock_area.dock_manager.floatingWidgets()) assert final_floating <= initial_floating + def test_load_profile_restores_floating_dock(self, advanced_dock_area, qtbot): + helper = profile_helper(advanced_dock_area) + settings = helper.open_user("floating_profile") + settings.clear() + + settings.setValue("profile/created_at", "2025-11-23T00:00:00Z") + settings.beginWriteArray(SETTINGS_KEYS["manifest"], 2) + + # Floating entry + settings.setArrayIndex(0) + settings.setValue("object_name", "FloatingWaveform") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.setValue("floating", True) + settings.setValue("floating_screen", "") + settings.setValue("floating_rel_x", 0.1) + settings.setValue("floating_rel_y", 0.1) + settings.setValue("floating_rel_w", 0.2) + settings.setValue("floating_rel_h", 0.2) + settings.setValue("floating_abs_x", 50) + settings.setValue("floating_abs_y", 50) + settings.setValue("floating_abs_w", 200) + settings.setValue("floating_abs_h", 150) + + # Anchored entry + settings.setArrayIndex(1) + settings.setValue("object_name", "EmbeddedWaveform") + settings.setValue("widget_class", "DarkModeButton") + settings.setValue("closable", True) + settings.setValue("floatable", True) + settings.setValue("movable", True) + settings.setValue("floating", False) + settings.setValue("floating_screen", "") + settings.setValue("floating_rel_x", 0.0) + settings.setValue("floating_rel_y", 0.0) + settings.setValue("floating_rel_w", 0.0) + settings.setValue("floating_rel_h", 0.0) + settings.setValue("floating_abs_x", 0) + settings.setValue("floating_abs_y", 0) + settings.setValue("floating_abs_w", 0) + settings.setValue("floating_abs_h", 0) + settings.endArray() + settings.sync() + + advanced_dock_area.delete_all() + advanced_dock_area.load_profile("floating_profile") + + qtbot.waitUntil(lambda: "FloatingWaveform" in advanced_dock_area.dock_map(), timeout=3000) + floating_dock = advanced_dock_area.dock_map()["FloatingWaveform"] + assert floating_dock.isFloating() + def test_screenshot_action(self, advanced_dock_area, tmpdir): """Test screenshot toolbar action.""" # Create a test screenshot file path in tmpdir From 73528eef18f3606c858d44795c394ed20311cdc9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 24 Nov 2025 11:33:45 +0100 Subject: [PATCH 150/161] fix(collapsible_tree_section): fix typo in the styleSheet --- .../widgets/containers/explorer/collapsible_tree_section.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index d2c3ebe13..f062e3d8d 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -110,7 +110,7 @@ def _update_appearance(self): self.header_button.setStyleSheet( """ - QPushButton {{ + QPushButton { font-weight: bold; text-align: left; margin: 0; @@ -118,7 +118,7 @@ def _update_appearance(self): border: none; background: transparent; icon-size: 20px 20px; - }} + } """ ) From ac9edd10d9c9ec0c0189a20cb844269d4ee83fc6 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 26 Nov 2025 17:08:26 +0100 Subject: [PATCH 151/161] fix(widget_state_manager): add 'updatesEnabled' to PROPERTY_TO_SKIP to avoid empty AdvancedDockArea when restore on init when hidden --- bec_widgets/utils/widget_state_manager.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index ae5632b86..6b11fb3f4 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -21,7 +21,15 @@ logger = bec_logger.logger -PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText", "locale", "styleSheet"] +PROPERTY_TO_SKIP = [ + "palette", + "font", + "windowIcon", + "windowIconText", + "locale", + "styleSheet", + "updatesEnabled", +] class WidgetStateManager: From c991faeb35e7c6596792950807351b9278b1a891 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 27 Nov 2025 15:08:34 +0100 Subject: [PATCH 152/161] fix(widget_state_manager): PROPERTIES_TO_SKIP are not restored even if in ini file --- bec_widgets/utils/widget_state_manager.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 6b11fb3f4..13505087b 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -29,6 +29,8 @@ "locale", "styleSheet", "updatesEnabled", + "objectName", + "visible", ] @@ -118,16 +120,8 @@ def _save_widget_state_qsettings( prop = meta.property(i) name = prop.name() - # Skip persisting QWidget visibility because container widgets (e.g. tab - # stacks, dock managers) manage that state themselves. Restoring a saved - # False can permanently hide a widget, while forcing True makes hidden - # tabs show on top. Leave the property to the parent widget instead. - if name == "visible": - continue - if ( - name == "objectName" - or name in PROPERTY_TO_SKIP + name in PROPERTY_TO_SKIP or not prop.isReadable() or not prop.isWritable() or not prop.isStored() # can be extended to fine filter @@ -184,7 +178,7 @@ def _load_widget_state_qsettings( for i in range(meta.propertyCount()): prop = meta.property(i) name = prop.name() - if name == "visible": + if name in PROPERTY_TO_SKIP: continue if settings.contains(name): value = settings.value(name) From 4c9fa274509436e874a33c89b82a36ea50fd7ee5 Mon Sep 17 00:00:00 2001 From: David Perl Date: Fri, 28 Nov 2025 14:10:51 +0100 Subject: [PATCH 153/161] fix: don't wait forever --- bec_widgets/cli/client_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index bdcbd05ff..622becc6c 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -392,7 +392,8 @@ def _gui_post_startup(self): timeout = 60 # Wait for 'bec' gui to be registered, this may take some time # After 60s timeout. Should this raise an exception on timeout? - while time.time() < time.time() + timeout: + start = time.monotonic() + while time.monotonic() < start + timeout: if len(list(self._server_registry.keys())) < 2 or not hasattr( self, self._anchor_widget ): From 5763830ef1503e9f4b15836ce026293573fbe17d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 27 Nov 2025 15:43:47 +0100 Subject: [PATCH 154/161] fix(motor_map): x/y motor are saved in properties --- bec_widgets/cli/client.py | 33 +++- .../widgets/plots/motor_map/motor_map.py | 147 ++++++++++++++---- .../toolbar_components/motor_selection.py | 108 ++++++++++--- tests/unit_tests/test_motor_map_next_gen.py | 65 +++++++- 4 files changed, 296 insertions(+), 57 deletions(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 2052c9b90..9406560ed 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -3480,6 +3480,34 @@ def y_log(self) -> "bool": Set Y-axis to log scale if True, linear if False. """ + @property + @rpc_call + def x_motor(self) -> "str": + """ + Name of the motor shown on the X axis. + """ + + @x_motor.setter + @rpc_call + def x_motor(self) -> "str": + """ + Name of the motor shown on the X axis. + """ + + @property + @rpc_call + def y_motor(self) -> "str": + """ + Name of the motor shown on the Y axis. + """ + + @y_motor.setter + @rpc_call + def y_motor(self) -> "str": + """ + Name of the motor shown on the Y axis. + """ + @property @rpc_call def legend_label_size(self) -> "int": @@ -3604,7 +3632,9 @@ def scatter_size(self) -> "int": """ @rpc_call - def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "None": + def map( + self, x_name: "str", y_name: "str", validate_bec: "bool" = True, suppress_errors=False + ) -> "None": """ Set the x and y motor names. @@ -3612,6 +3642,7 @@ def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "Non x_name(str): The name of the x motor. y_name(str): The name of the y motor. validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True. + suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. """ @rpc_call diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index e8947350d..0df779201 100644 --- a/bec_widgets/widgets/plots/motor_map/motor_map.py +++ b/bec_widgets/widgets/plots/motor_map/motor_map.py @@ -17,7 +17,9 @@ from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import ( - MotorSelectionAction, + MotorSelection, + MotorSelectionConnection, + motor_selection_bundle, ) from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode @@ -126,6 +128,10 @@ class MotorMap(PlotBase): "x_log.setter", "y_log", "y_log.setter", + "x_motor", + "x_motor.setter", + "y_motor", + "y_motor.setter", "legend_label_size", "legend_label_size.setter", "attach", @@ -195,11 +201,10 @@ def _init_motor_map_toolbar(self): """ Initialize the toolbar for the motor map widget. """ - motor_selection = MotorSelectionAction(parent=self) - self.toolbar.add_action("motor_selection", motor_selection) - - motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed) - motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed) + self.toolbar.add_bundle(motor_selection_bundle(self.toolbar.components)) + self.toolbar.connect_bundle( + "motor_selection", MotorSelectionConnection(self.toolbar.components, target_widget=self) + ) self.toolbar.components.get_action("reset_legend").action.setVisible(False) @@ -228,12 +233,19 @@ def _init_motor_map_toolbar(self): if self.ui_mode == UIMode.POPUP: bundles.append("axis_popup") self.toolbar.show_bundles(bundles) + self._sync_motor_map_selection_toolbar() @SafeSlot() def on_motor_selection_changed(self, _): - action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection") - motor_x = action.motor_x.currentText() - motor_y = action.motor_y.currentText() + action = self.toolbar.components.get_action("motor_selection") + motor_selection: MotorSelection = action.widget + motor_x = motor_selection.motor_x.currentText() + motor_y = motor_selection.motor_y.currentText() + + if motor_x and not self._validate_motor_name(motor_x): + return + if motor_y and not self._validate_motor_name(motor_y): + return if motor_x != "" and motor_y != "": if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name: @@ -286,6 +298,36 @@ def _motor_map_settings_closed(self): # Widget Specific Properties ################################################################################ + @SafeProperty(str) + def x_motor(self) -> str: + """Name of the motor shown on the X axis.""" + return self.config.x_motor.name or "" + + @x_motor.setter + def x_motor(self, motor_name: str) -> None: + motor_name = motor_name or "" + if motor_name == (self.config.x_motor.name or ""): + return + if motor_name and self.y_motor: + self.map(motor_name, self.y_motor, suppress_errors=True) + return + self._set_motor_name(axis="x", motor_name=motor_name) + + @SafeProperty(str) + def y_motor(self) -> str: + """Name of the motor shown on the Y axis.""" + return self.config.y_motor.name or "" + + @y_motor.setter + def y_motor(self, motor_name: str) -> None: + motor_name = motor_name or "" + if motor_name == (self.config.y_motor.name or ""): + return + if motor_name and self.x_motor: + self.map(self.x_motor, motor_name, suppress_errors=True) + return + self._set_motor_name(axis="y", motor_name=motor_name) + # color_scatter for designer, color for CLI to not bother users with QColor @SafeProperty("QColor") def color_scatter(self) -> QtGui.QColor: @@ -427,11 +469,47 @@ def scatter_size(self, scatter_size: int) -> None: self.update_signal.emit() self.property_changed.emit("scatter_size", scatter_size) + def _validate_motor_name(self, motor_name: str) -> bool: + """ + Check motor validity against BEC without raising. + + Args: + motor_name(str): Name of the motor to validate. + + Returns: + bool: True if motor is valid, False otherwise. + """ + if not motor_name: + return False + try: + self.entry_validator.validate_signal(motor_name, None) + return True + except Exception: # noqa: BLE001 - validator can raise multiple error types + return False + + def _set_motor_name(self, axis: str, motor_name: str, *, sync_toolbar: bool = True) -> None: + """ + Update stored motor name for given axis and optionally refresh the toolbar selection. + """ + motor_name = motor_name or "" + motor_config = self.config.x_motor if axis == "x" else self.config.y_motor + + if motor_config.name == motor_name: + return + + motor_config.name = motor_name + self.property_changed.emit(f"{axis}_motor", motor_name) + + if sync_toolbar: + self._sync_motor_map_selection_toolbar() + ################################################################################ # High Level methods for API ################################################################################ @SafeSlot() - def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None: + def map( + self, x_name: str, y_name: str, validate_bec: bool = True, suppress_errors=False + ) -> None: """ Set the x and y motor names. @@ -439,15 +517,23 @@ def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None: x_name(str): The name of the x motor. y_name(str): The name of the y motor. validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True. + suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied. """ self.plot_item.clear() if validate_bec: - self.entry_validator.validate_signal(x_name, None) - self.entry_validator.validate_signal(y_name, None) - - self.config.x_motor.name = x_name - self.config.y_motor.name = y_name + if suppress_errors: + try: + self.entry_validator.validate_signal(x_name, None) + self.entry_validator.validate_signal(y_name, None) + except Exception: + return + else: + self.entry_validator.validate_signal(x_name, None) + self.entry_validator.validate_signal(y_name, None) + + self._set_motor_name(axis="x", motor_name=x_name, sync_toolbar=False) + self._set_motor_name(axis="y", motor_name=y_name, sync_toolbar=False) motor_x_limit = self._get_motor_limit(self.config.x_motor.name) motor_y_limit = self._get_motor_limit(self.config.y_motor.name) @@ -774,21 +860,24 @@ def _sync_motor_map_selection_toolbar(self): """ Sync the motor map selection toolbar with the current motor map. """ - motor_selection = self.toolbar.components.get_action("motor_selection") - - motor_x = motor_selection.motor_x.currentText() - motor_y = motor_selection.motor_y.currentText() + try: + motor_selection_action = self.toolbar.components.get_action("motor_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + logger.warning(f"MotorMap ({self.object_name}) toolbar was not ready during init.") + return + if motor_selection_action is None: + return + motor_selection: MotorSelection = motor_selection_action.widget + target_x = self.config.x_motor.name or "" + target_y = self.config.y_motor.name or "" + + if ( + motor_selection.motor_x.currentText() == target_x + and motor_selection.motor_y.currentText() == target_y + ): + return - if motor_x != self.config.x_motor.name: - motor_selection.motor_x.blockSignals(True) - motor_selection.motor_x.set_device(self.config.x_motor.name) - motor_selection.motor_x.check_validity(self.config.x_motor.name) - motor_selection.motor_x.blockSignals(False) - if motor_y != self.config.y_motor.name: - motor_selection.motor_y.blockSignals(True) - motor_selection.motor_y.set_device(self.config.y_motor.name) - motor_selection.motor_y.check_validity(self.config.y_motor.name) - motor_selection.motor_y.blockSignals(False) + motor_selection.set_motors(target_x, target_y) ################################################################################ # Export Methods diff --git a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py index a37c3f210..2307fa765 100644 --- a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py +++ b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py @@ -1,43 +1,55 @@ -from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget +from qtpy.QtWidgets import QHBoxLayout, QWidget -from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction +from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox -class MotorSelectionAction(ToolBarAction): +class MotorSelection(QWidget): def __init__(self, parent=None): - super().__init__(icon_path=None, tooltip=None, checkable=False) - self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + super().__init__(parent=parent) + + self.motor_x = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER]) self.motor_x.addItem("", None) self.motor_x.setCurrentText("") self.motor_x.setToolTip("Select Motor X") self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x)) - self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + self.motor_x.setEditable(True) + self.motor_y = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER]) self.motor_y.addItem("", None) self.motor_y.setCurrentText("") self.motor_y.setToolTip("Select Motor Y") self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y)) + self.motor_y.setEditable(True) - self.container = QWidget(parent) - layout = QHBoxLayout(self.container) + layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.motor_x) layout.addWidget(self.motor_y) - self.container.setLayout(layout) - self.action = self.container - - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - """ - Adds the widget to the toolbar. - - Args: - toolbar (QToolBar): The toolbar to add the widget to. - target (QWidget): The target widget for the action. - """ - toolbar.addWidget(self.container) + def set_motors(self, motor_x: str | None, motor_y: str | None) -> None: + """Set the displayed motors without emitting selection signals.""" + motor_x = motor_x or "" + motor_y = motor_y or "" + self.motor_x.blockSignals(True) + self.motor_y.blockSignals(True) + try: + if motor_x: + self.motor_x.set_device(motor_x) + self.motor_x.check_validity(motor_x) + else: + self.motor_x.setCurrentText("") + if motor_y: + self.motor_y.set_device(motor_y) + self.motor_y.check_validity(motor_y) + else: + self.motor_y.setCurrentText("") + finally: + self.motor_x.blockSignals(False) + self.motor_y.blockSignals(False) def cleanup(self): """ @@ -47,5 +59,57 @@ def cleanup(self): self.motor_x.deleteLater() self.motor_y.close() self.motor_y.deleteLater() - self.container.close() - self.container.deleteLater() + + +def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a workspace toolbar bundle for MotorMap. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The workspace toolbar bundle. + """ + + motor_selection_widget = MotorSelection(parent=components.toolbar) + components.add_safe( + "motor_selection", WidgetAction(widget=motor_selection_widget, adjust_size=False) + ) + + bundle = ToolbarBundle("motor_selection", components) + bundle.add_action("motor_selection") + return bundle + + +class MotorSelectionConnection(BundleConnection): + """ + Connection helper for the motor selection bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + self.bundle_name = "motor_selection" + self.components = components + self.target_widget = target_widget + self._connected = False + + def _widget(self) -> MotorSelection: + return self.components.get_action("motor_selection").widget + + def connect(self): + if self._connected: + return + widget = self._widget() + widget.motor_x.currentTextChanged.connect(self.target_widget.on_motor_selection_changed) + widget.motor_y.currentTextChanged.connect(self.target_widget.on_motor_selection_changed) + self._connected = True + + def disconnect(self): + if not self._connected: + return + widget = self._widget() + widget.motor_x.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed) + widget.motor_y.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed) + self._connected = False + widget.cleanup() diff --git a/tests/unit_tests/test_motor_map_next_gen.py b/tests/unit_tests/test_motor_map_next_gen.py index 277b3be17..4e296f63d 100644 --- a/tests/unit_tests/test_motor_map_next_gen.py +++ b/tests/unit_tests/test_motor_map_next_gen.py @@ -1,5 +1,4 @@ -import numpy as np -import pyqtgraph as pg +from qtpy.QtTest import QSignalSpy from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap from tests.unit_tests.client_mocks import mocked_client @@ -274,18 +273,74 @@ def test_motor_map_toolbar_selection(qtbot, mocked_client): # Verify toolbar bundle was created during initialization motor_selection = mm.toolbar.components.get_action("motor_selection") - motor_selection.motor_x.setCurrentText("samx") - motor_selection.motor_y.setCurrentText("samy") + motor_selection.widget.motor_x.setCurrentText("samx") + motor_selection.widget.motor_y.setCurrentText("samy") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samy" - motor_selection.motor_y.setCurrentText("samz") + motor_selection.widget.motor_y.setCurrentText("samz") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samz" +def test_motor_selection_set_motors_blocks_signals(qtbot, mocked_client): + """Ensure set_motors updates both comboboxes without emitting change signals.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + motor_selection = mm.toolbar.components.get_action("motor_selection").widget + + spy_x = QSignalSpy(motor_selection.motor_x.currentTextChanged) + spy_y = QSignalSpy(motor_selection.motor_y.currentTextChanged) + + motor_selection.set_motors("samx", "samy") + + assert motor_selection.motor_x.currentText() == "samx" + assert motor_selection.motor_y.currentText() == "samy" + assert spy_x.count() == 0 + assert spy_y.count() == 0 + + +def test_motor_properties_partial_then_complete_map(qtbot, mocked_client): + """Setting x then y via properties should map once both are valid.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + + spy = QSignalSpy(mm.property_changed) + mm.x_motor = "samx" + + assert mm.config.x_motor.name == "samx" + assert mm.config.y_motor.name is None + assert mm._trace is None # map not triggered yet + assert spy.at(0) == ["x_motor", "samx"] + + mm.y_motor = "samy" + + assert mm.config.x_motor.name == "samx" + assert mm.config.y_motor.name == "samy" + assert mm._trace is not None # map called once both valid + assert spy.at(1) == ["y_motor", "samy"] + assert len(mm._buffer["x"]) == 1 + assert len(mm._buffer["y"]) == 1 + + +def test_set_motor_name_emits_and_syncs_toolbar(qtbot, mocked_client): + """_set_motor_name should emit property changes and sync toolbar widgets.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + motor_selection = mm.toolbar.components.get_action("motor_selection").widget + + spy = QSignalSpy(mm.property_changed) + mm._set_motor_name("x", "samx") + + assert mm.config.x_motor.name == "samx" + assert motor_selection.motor_x.currentText() == "samx" + assert spy.at(0) == ["x_motor", "samx"] + + # Calling with same name should be a no-op + initial_count = spy.count() + mm._set_motor_name("x", "samx") + assert spy.count() == initial_count + + def test_motor_map_settings_dialog(qtbot, mocked_client): """Test the settings dialog for the motor map.""" mm = create_widget(qtbot, MotorMap, client=mocked_client, popups=True) From be132ad823fa2c658905bd4c3ccf693cc0a08fc3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 27 Nov 2025 16:09:58 +0100 Subject: [PATCH 155/161] fix(advanced_dock_area): cleanup the startup script of ads example --- .../advanced_dock_area/advanced_dock_area.py | 22 +++---------------- 1 file changed, 3 insertions(+), 19 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 07e878ee6..2e4bce02e 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 @@ -941,30 +941,14 @@ def cleanup(self): if __name__ == "__main__": # pragma: no cover import sys - from qtpy.QtWidgets import QTabWidget - app = QApplication(sys.argv) apply_theme("dark") dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() - central = QWidget() - layout = QVBoxLayout(central) - window.setCentralWidget(central) - - # two dock areas stacked vertically no instance ids - ads = AdvancedDockArea(mode="creator", enable_profile_management=True) - ads2 = AdvancedDockArea(mode="creator", enable_profile_management=True) - layout.addWidget(ads, 1) - layout.addWidget(ads2, 1) - - # two dock areas inside a tab widget - tabs = QTabWidget(parent=central) - ads3 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab3") - ads4 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab4") - tabs.addTab(ads3, "Workspace 3") - tabs.addTab(ads4, "Workspace 4") - layout.addWidget(tabs, 1) + ads = AdvancedDockArea(mode="creator", enable_profile_management=True, root_widget=True) + + window.setCentralWidget(ads) window.show() window.resize(800, 1000) From 1fbaa517a20205705ded74b9f1f062271e96bc23 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 26 Nov 2025 15:06:16 +0100 Subject: [PATCH 156/161] fix(collapsible_tree_section): update title button styling to use property variant "title"; closes #951 --- .../explorer/collapsible_tree_section.py | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index f062e3d8d..ca15a3ce0 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -5,7 +5,6 @@ from qtpy.QtGui import QDrag from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget -from bec_widgets.utils.colors import get_theme_palette from bec_widgets.utils.error_popups import SafeProperty @@ -49,6 +48,8 @@ def __init__( # Create header button self.header_button = QPushButton() + # Apply theme variant for title styling + self.header_button.setProperty("variant", "title") self.header_button.clicked.connect(self.toggle_expanded) # Enable drag and drop for reordering @@ -105,23 +106,6 @@ def _update_appearance(self): self.header_button.setIcon(icon) self.header_button.setText(self.title) - # Get theme colors - palette = get_theme_palette() - - self.header_button.setStyleSheet( - """ - QPushButton { - font-weight: bold; - text-align: left; - margin: 0; - padding: 0px; - border: none; - background: transparent; - icon-size: 20px 20px; - } - """ - ) - def toggle_expanded(self): """Toggle the expanded state and update size policy""" self.expanded = not self.expanded From 12103724a1fe14e567b8782eb49785ef5da7aff2 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 5 Dec 2025 16:46:36 +0100 Subject: [PATCH 157/161] fix(client): motor map docstring fixed --- bec_widgets/cli/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 9406560ed..5b4955edb 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -3642,7 +3642,7 @@ def map( x_name(str): The name of the x motor. y_name(str): The name of the y motor. validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True. - suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. + suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied. """ @rpc_call From c60cb31eaa92562796c73552d72fb348d37de6f4 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 1 Dec 2025 12:43:42 +0100 Subject: [PATCH 158/161] fix(jupyter_console_window): added hook to current app instance for quick debugging --- .../examples/jupyter_console/jupyter_console_window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index ed0dc0356..9b108b491 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -25,7 +25,6 @@ QWidget, ) -from bec_widgets import BECWidget from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.widget_io import WidgetHierarchy as wh @@ -48,6 +47,7 @@ def __init__(self, parent=None, *args, **kwargs): self._widgets_by_name: Dict[str, QWidget] = {} self._init_ui() + self.app = QApplication.instance() # expose helper API and basics in the inprocess console if self.console.inprocess is True: @@ -94,7 +94,7 @@ def available_widgets(self): return list(widget_handler.widget_classes.keys()) self.jc = _ConsoleAPI(self) - self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh}) + self._push_to_console({"jc": self.jc, "np": np, "pg": pg, "wh": wh, "app": self.app}) def _init_ui(self): self.layout = QHBoxLayout(self) From 6836036507d1bee51cc27c5e8aa0a0b23fd31565 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 1 Dec 2025 13:13:38 +0100 Subject: [PATCH 159/161] feat(status_bar): added StatusToolBar with status actions and Broker --- .../views/developer_view/developer_view.py | 2 + bec_widgets/applications/views/view.py | 39 ++- bec_widgets/utils/toolbars/actions.py | 252 +++++++++++++++- bec_widgets/utils/toolbars/status_bar.py | 283 ++++++++++++++++++ tests/unit_tests/test_status_bar.py | 97 ++++++ 5 files changed, 671 insertions(+), 2 deletions(-) create mode 100644 bec_widgets/utils/toolbars/status_bar.py create mode 100644 tests/unit_tests/test_status_bar.py diff --git a/bec_widgets/applications/views/developer_view/developer_view.py b/bec_widgets/applications/views/developer_view/developer_view.py index 6f177c752..eb3350e34 100644 --- a/bec_widgets/applications/views/developer_view/developer_view.py +++ b/bec_widgets/applications/views/developer_view/developer_view.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from qtpy.QtWidgets import QWidget from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py index 635f68b15..aee87f311 100644 --- a/bec_widgets/applications/views/view.py +++ b/bec_widgets/applications/views/view.py @@ -18,6 +18,7 @@ ) from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.status_bar import StatusToolBar 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 @@ -69,6 +70,7 @@ class ViewBase(QWidget): parent (QWidget | None): Parent widget. id (str | None): Optional view id, useful for debugging or introspection. title (str | None): Optional human-readable title. + show_status (bool): Whether to show a status toolbar at the top of the view. """ def __init__( @@ -78,6 +80,8 @@ def __init__( *, id: str | None = None, title: str | None = None, + show_status: bool = False, + status_names: list[str] | None = None, ): super().__init__(parent=parent) self.content: QWidget | None = None @@ -88,15 +92,48 @@ def __init__( lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) + self.status_bar: StatusToolBar | None = None + if show_status: + # If explicit status names are provided, default to showing only those. + show_all = status_names is None + self.setup_status_bar(show_all_status=show_all, status_names=status_names) + 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.layout().removeWidget(self.content) self.content.setParent(None) + self.content.close() + self.content.deleteLater() self.content = content - self.layout().addWidget(content) + if self.status_bar is not None: + insert_at = self.layout().indexOf(self.status_bar) + 1 + self.layout().insertWidget(insert_at, content) + else: + self.layout().addWidget(content) + + def setup_status_bar( + self, *, show_all_status: bool = True, status_names: list[str] | None = None + ) -> None: + """Create and attach a status toolbar managed by the status broker.""" + if self.status_bar is not None: + return + names_arg = None if show_all_status else status_names + self.status_bar = StatusToolBar(parent=self, names=names_arg) + self.layout().addWidget(self.status_bar) + + def set_status( + self, name: str = "main", *, state=None, text: str | None = None, tooltip: str | None = None + ) -> None: + """Manually set a status item on the status bar.""" + if self.status_bar is None: + self.setup_status_bar(show_all_status=True) + if self.status_bar is None: + return + self.status_bar.set_status(name=name, state=state, text=text, tooltip=tooltip) @SafeSlot() def on_enter(self) -> None: diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 5c0b0955e..ff5c14c21 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -5,7 +5,8 @@ import weakref from abc import ABC, abstractmethod from contextlib import contextmanager -from typing import Dict, Literal +from enum import Enum +from typing import Dict, Literal, Union from bec_lib.device import ReadoutPriority from bec_lib.logger import bec_logger @@ -15,6 +16,7 @@ from qtpy.QtWidgets import ( QApplication, QComboBox, + QGraphicsDropShadowEffect, QHBoxLayout, QLabel, QMenu, @@ -26,6 +28,7 @@ ) import bec_widgets +from bec_widgets.utils.colors import AccentColors, get_accent_colors from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox @@ -101,6 +104,205 @@ def handleLongPress(self): self.showMenu() +class StatusState(str, Enum): + DEFAULT = "default" + HIGHLIGHT = "highlight" + WARNING = "warning" + EMERGENCY = "emergency" + SUCCESS = "success" + + +class StatusIndicatorWidget(QWidget): + """Pill-shaped status indicator with icon + label using accent colors.""" + + def __init__( + self, parent=None, text: str = "Ready", state: StatusState | str = StatusState.DEFAULT + ): + super().__init__(parent) + self.setObjectName("StatusIndicatorWidget") + self._text = text + self._state = self._normalize_state(state) + self._theme_connected = False + + layout = QHBoxLayout(self) + layout.setContentsMargins(6, 2, 8, 2) + layout.setSpacing(6) + + self._icon_label = QLabel(self) + self._icon_label.setFixedSize(18, 18) + + self._text_label = QLabel(self) + self._text_label.setText(self._text) + + layout.addWidget(self._icon_label) + layout.addWidget(self._text_label) + + # Give it a consistent pill height + self.setMinimumHeight(24) + self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + + # Soft shadow similar to notification banners + self._shadow = QGraphicsDropShadowEffect(self) + self._shadow.setBlurRadius(18) + self._shadow.setOffset(0, 2) + self.setGraphicsEffect(self._shadow) + + self._apply_state(self._state) + self._connect_theme_change() + + def set_state(self, state: Union[StatusState, str]): + """Update state and refresh visuals.""" + self._state = self._normalize_state(state) + self._apply_state(self._state) + + def set_text(self, text: str): + """Update the displayed text.""" + self._text = text + self._text_label.setText(text) + + def _apply_state(self, state: StatusState): + palette = self._resolve_accent_colors() + color_attr = { + StatusState.DEFAULT: "default", + StatusState.HIGHLIGHT: "highlight", + StatusState.WARNING: "warning", + StatusState.EMERGENCY: "emergency", + StatusState.SUCCESS: "success", + }.get(state, "default") + base_color = getattr(palette, color_attr, None) or getattr( + palette, "default", QColor("gray") + ) + + # Apply style first (returns text color for label) + text_color = self._update_style(base_color, self._theme_fg_color()) + theme_name = self._theme_name() + + # Choose icon per state + icon_name_map = { + StatusState.DEFAULT: "check_circle", + StatusState.HIGHLIGHT: "check_circle", + StatusState.SUCCESS: "check_circle", + StatusState.WARNING: "warning", + StatusState.EMERGENCY: "dangerous", + } + icon_name = icon_name_map.get(state, "check_circle") + + # Icon color: + # - Dark mode: follow text color (usually white) for high contrast. + # - Light mode: use a stronger version of the accent color for a colored glyph + # that stands out on the pastel pill background. + if theme_name == "light": + icon_q = QColor(base_color) + icon_color = icon_q.name(QColor.HexRgb) + else: + icon_color = text_color + + icon = material_icon( + icon_name, size=(18, 18), convert_to_pixmap=False, filled=True, color=icon_color + ) + if not icon.isNull(): + self._icon_label.setPixmap(icon.pixmap(18, 18)) + + def _update_style(self, color: QColor, fg_color: QColor) -> str: + # Ensure the widget actually paints its own background + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + + fg = QColor(fg_color) + text_color = fg.name(QColor.HexRgb) + + theme_name = self._theme_name() + + base = QColor(color) + + start = QColor(base) + end = QColor(base) + border = QColor(base) + + if theme_name == "light": + start.setAlphaF(0.20) + end.setAlphaF(0.06) + else: + start.setAlphaF(0.35) + end.setAlphaF(0.12) + border = border.darker(120) + + # shadow color tuned per theme to match notification banners + if hasattr(self, "_shadow"): + if theme_name == "light": + shadow_color = QColor(15, 23, 42, 60) # softer shadow on light bg + else: + shadow_color = QColor(0, 0, 0, 160) + self._shadow.setColor(shadow_color) + + # Use a fixed radius for a stable pill look inside toolbars + radius = 10 + + self.setStyleSheet( + f""" + #StatusIndicatorWidget {{ + background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:1, + stop:0 {start.name(QColor.HexArgb)}, stop:1 {end.name(QColor.HexArgb)}); + border: 1px solid {border.name(QColor.HexRgb)}; + border-radius: {radius}px; + padding: 2px 8px; + }} + #StatusIndicatorWidget QLabel {{ + color: {text_color}; + background: transparent; + }} + """ + ) + return text_color + + def _theme_fg_color(self) -> QColor: + app = QApplication.instance() + theme = getattr(app, "theme", None) + if theme is not None and hasattr(theme, "color"): + try: + fg = theme.color("FG") + if isinstance(fg, QColor): + return fg + except Exception: + pass + palette = self._resolve_accent_colors() + base = getattr(palette, "default", QColor("white")) + luminance = (0.299 * base.red() + 0.587 * base.green() + 0.114 * base.blue()) / 255 + return QColor("#000000") if luminance > 0.65 else QColor("#ffffff") + + def _theme_name(self) -> str: + app = QApplication.instance() + theme = getattr(app, "theme", None) + name = getattr(theme, "theme", None) + if isinstance(name, str): + return name.lower() + return "dark" + + def _connect_theme_change(self): + if self._theme_connected: + return + app = QApplication.instance() + theme = getattr(app, "theme", None) + if theme is not None and hasattr(theme, "theme_changed"): + try: + theme.theme_changed.connect(lambda _: self._apply_state(self._state)) + self._theme_connected = True + except Exception: + pass + + @staticmethod + def _normalize_state(state: Union[StatusState, str]) -> StatusState: + if isinstance(state, StatusState): + return state + try: + return StatusState(state) + except ValueError: + return StatusState.DEFAULT + + @staticmethod + def _resolve_accent_colors() -> AccentColors: + return get_accent_colors() + + class ToolBarAction(ABC): """ Abstract base class for toolbar actions. @@ -147,6 +349,54 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar.addSeparator() +class StatusIndicatorAction(ToolBarAction): + """Toolbar action hosting a LED indicator and status text.""" + + def __init__( + self, + *, + text: str = "Ready", + state: Union[StatusState, str] = StatusState.DEFAULT, + tooltip: str | None = None, + ): + super().__init__(icon_path=None, tooltip=tooltip or "View status", checkable=False) + self._text = text + self._state: StatusState = StatusIndicatorWidget._normalize_state(state) + self.widget: StatusIndicatorWidget | None = None + self.tooltip = tooltip or "" + + def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): + if ( + self.widget is None + or self.widget.parent() is None + or self.widget.parent() is not toolbar + ): + self.widget = StatusIndicatorWidget(parent=toolbar, text=self._text, state=self._state) + self.action = toolbar.addWidget(self.widget) + self.action.setText(self._text) + self.set_tooltip(self.tooltip) + + def set_state(self, state: Union[StatusState, str]): + self._state = StatusIndicatorWidget._normalize_state(state) + if self.widget is not None: + self.widget.set_state(self._state) + + def set_text(self, text: str): + self._text = text + if self.widget is not None: + self.widget.set_text(text) + if hasattr(self, "action") and self.action is not None: + self.action.setText(text) + + def set_tooltip(self, tooltip: str | None): + """Set tooltip on both the underlying widget and the QWidgetAction.""" + self.tooltip = tooltip or "" + if self.widget is not None: + self.widget.setToolTip(self.tooltip) + if hasattr(self, "action") and self.action is not None: + self.action.setToolTip(self.tooltip) + + class QtIconAction(IconAction): def __init__( self, diff --git a/bec_widgets/utils/toolbars/status_bar.py b/bec_widgets/utils/toolbars/status_bar.py new file mode 100644 index 000000000..d591ec859 --- /dev/null +++ b/bec_widgets/utils/toolbars/status_bar.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger +from bec_lib.messages import BeamlineConditionUpdateEntry +from qtpy.QtCore import QObject, QTimer, Signal + +from bec_widgets.utils.bec_connector import BECConnector +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import StatusIndicatorAction, StatusState +from bec_widgets.utils.toolbars.toolbar import ModularToolBar + +logger = bec_logger.logger + + +class BECStatusBroker(BECConnector, QObject): + """Listen to BEC beamline condition endpoints and emit structured signals.""" + + _instance: "BECStatusBroker | None" = None + _initialized: bool = False + + available_updated = Signal(list) # list of conditions available + status_updated = Signal(str, dict) # name, status update + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self, parent=None, gui_id: str | None = None, client=None, **kwargs): + if self._initialized: + return + super().__init__(parent=parent, gui_id=gui_id, client=client, **kwargs) + self._watched: set[str] = set() + self.bec_dispatcher.connect_slot( + self.on_available, MessageEndpoints.available_beamline_conditions() + ) + + self._initialized = True + self.refresh_available() + + def refresh_available(self): + """Fetch the current set of beamline conditions once.""" + try: + msg = self.client.connector.get_last(MessageEndpoints.available_beamline_conditions()) + logger.info(f"StatusBroker: fetched available conditions payload: {msg}") + if msg: + self.on_available(msg.get("data").content, None) + except Exception as exc: # pragma: no cover - runtime env + logger.debug(f"Could not fetch available conditions: {exc}") + + @SafeSlot(dict, dict) + def on_available(self, data: dict, meta: dict | None = None): + condition_list = data.get("conditions") # latest one from the stream + self.available_updated.emit(condition_list) + for condition in condition_list: + name = condition.name + if name: + self.watch_condition(name) + + def watch_condition(self, name: str): + """Subscribe to updates for a single beamline condition.""" + if name in self._watched: + return + self._watched.add(name) + endpoint = MessageEndpoints.beamline_condition(name) + logger.info(f"StatusBroker: watching condition '{name}' on {endpoint.endpoint}") + self.bec_dispatcher.connect_slot(self.on_condition, endpoint) + self.fetch_condition(name) + + def fetch_condition(self, name: str): + """Fetch the current value of a beamline condition once.""" + endpoint = MessageEndpoints.beamline_condition(name) + try: + msg = self.client.connector.get_last(endpoint) + logger.info(f"StatusBroker: fetched condition '{name}' payload: {msg}") + if msg: + self.on_condition(msg.get("data").content, None) + except Exception as exc: # pragma: no cover - runtime env + logger.debug(f"Could not fetch condition {name}: {exc}") + + @SafeSlot(dict, dict) + def on_condition(self, data: dict, meta: dict | None = None): + name = data.get("name") + if not name: + return + logger.info(f"StatusBroker: condition update for '{name}' -> {data}") + self.status_updated.emit(str(name), data) + + @classmethod + def reset_singleton(cls): + """ + Reset the singleton instance of the BECStatusBroker. + """ + cls._instance = None + cls._initialized = False + + +class StatusToolBar(ModularToolBar): + """Status toolbar that auto-manages beamline condition indicators.""" + + STATUS_MAP: dict[str, StatusState] = { + "normal": StatusState.SUCCESS, + "warning": StatusState.WARNING, + "alarm": StatusState.EMERGENCY, + } + + def __init__(self, parent=None, names: list[str] | None = None, **kwargs): + super().__init__(parent=parent, orientation="horizontal", **kwargs) + self.setObjectName("StatusToolbar") + self._status_bundle = self.new_bundle("status") + self.show_bundles(["status"]) + self._apply_status_toolbar_style() + + self.allowed_names: set[str] | None = set(names) if names is not None else None + logger.info(f"StatusToolbar init allowed_names={self.allowed_names}") + + self.broker = BECStatusBroker() + self.broker.available_updated.connect(self.on_available_updated) + self.broker.status_updated.connect(self.on_status_updated) + + QTimer.singleShot(0, self.refresh_from_broker) + + def refresh_from_broker(self) -> None: + + if self.allowed_names is None: + self.broker.refresh_available() + else: + for name in self.allowed_names: + if not self.components.exists(name): + # Pre-create a placeholder pill so it is visible even before data arrives. + self.add_status_item( + name=name, text=name, state=StatusState.DEFAULT, tooltip=None + ) + self.broker.watch_condition(name) + + def _apply_status_toolbar_style(self) -> None: + self.setStyleSheet( + "QToolBar#StatusToolbar {" + f" background-color: {self.background_color};" + " border: none;" + " border-bottom: 1px solid palette(mid);" + "}" + ) + + # -------- Slots for updates -------- + @SafeSlot(list) + def on_available_updated(self, available_conditions: list): + """Process the available conditions stream and start watching them.""" + # Keep track of current names from the broker to remove stale ones. + current_names: set[str] = set() + for condition in available_conditions: + if not isinstance(condition, BeamlineConditionUpdateEntry): + continue + name = condition.name + title = condition.title or name + if not name: + continue + current_names.add(name) + logger.info(f"StatusToolbar: discovered condition '{name}' title='{title}'") + # auto-add unless filtered out + if self.allowed_names is None or name in self.allowed_names: + self.add_status_item(name=name, text=title, state=StatusState.DEFAULT, tooltip=None) + else: + # keep hidden but present for context menu toggling + self.add_status_item(name=name, text=title, state=StatusState.DEFAULT, tooltip=None) + act = self.components.get_action(name) + if act and act.action: + act.action.setVisible(False) + + # Remove actions that are no longer present in available_conditions. + known_actions = [ + n for n in self.components._components.keys() if n not in ("separator",) + ] # direct access used for clean-up + for name in known_actions: + if name not in current_names: + logger.info(f"StatusToolbar: removing stale condition '{name}'") + try: + self.components.remove_action(name) + except Exception as exc: + logger.warning(f"Failed to remove stale condition '{name}': {exc}") + self.refresh() + + @SafeSlot(str, dict) + def on_status_updated(self, name: str, payload: dict): # TODO finish update logic + """Update a status pill when a condition update arrives.""" + state = self.STATUS_MAP.get(str(payload.get("status", "")).lower(), StatusState.DEFAULT) + action = self.components.get_action(name) if self.components.exists(name) else None + + # Only update the label when a title is explicitly provided; otherwise keep current text. + title = payload.get("title") or None + text = title + if text is None and action is None: + text = payload.get("name") or name + + if "message" in payload: + tooltip = payload.get("message") or "" + else: + tooltip = None + logger.info( + f"StatusToolbar: update condition '{name}' -> state={state} text='{text}' tooltip='{tooltip}'" + ) + self.set_status(name=name, text=text, state=state, tooltip=tooltip) + + # -------- Items Management -------- + def add_status_item( + self, + name: str, + *, + text: str = "Ready", + state: StatusState | str = StatusState.DEFAULT, + tooltip: str | None = None, + ) -> StatusIndicatorAction | None: + """ + Add or update a named status item in the toolbar. + After you added all actions, call `toolbar.refresh()` to update the display. + + Args: + name(str): Unique name for the status item. + text(str): Text to display in the status item. + state(StatusState | str): State of the status item. + tooltip(str | None): Optional tooltip for the status item. + + Returns: + StatusIndicatorAction | None: The created or updated status action, or None if toolbar is not initialized. + """ + if self._status_bundle is None: + return + if self.components.exists(name): + return + + action = StatusIndicatorAction(text=text, state=state, tooltip=tooltip) + return self.add_status_action(name, action) + + def add_status_action( + self, name: str, action: StatusIndicatorAction + ) -> StatusIndicatorAction | None: + """ + Attach an existing StatusIndicatorAction to the status toolbar. + After you added all actions, call `toolbar.refresh()` to update the display. + + Args: + name(str): Unique name for the status item. + action(StatusIndicatorAction): The status action to add. + + Returns: + StatusIndicatorAction | None: The added status action, or None if toolbar is not initialized. + """ + self.components.add_safe(name, action) + self.get_bundle("status").add_action(name) + self.refresh() + self.broker.fetch_condition(name) + return action + + def set_status( + self, + name: str = "main", + *, + state: StatusState | str | None = None, + text: str | None = None, + tooltip: str | None = None, + ) -> None: + """ + Update the status item with the given name, creating it if necessary. + + Args: + name(str): Unique name for the status item. + state(StatusState | str | None): New state for the status item. + text(str | None): New text for the status item. + """ + action = self.components.get_action(name) if self.components.exists(name) else None + if action is None: + action = self.add_status_item( + name, text=text or "Ready", state=state or "default", tooltip=tooltip + ) + if action is None: + return + if state is not None: + action.set_state(state) + if text is not None: + action.set_text(text) + if tooltip is not None and hasattr(action, "set_tooltip"): + action.set_tooltip(tooltip) diff --git a/tests/unit_tests/test_status_bar.py b/tests/unit_tests/test_status_bar.py new file mode 100644 index 000000000..72e2584f0 --- /dev/null +++ b/tests/unit_tests/test_status_bar.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pytest +from bec_lib.messages import BeamlineConditionUpdateEntry +from qtpy.QtWidgets import QToolBar + +from bec_widgets.utils.toolbars.actions import StatusIndicatorAction, StatusIndicatorWidget +from bec_widgets.utils.toolbars.status_bar import BECStatusBroker, StatusToolBar + +from .client_mocks import mocked_client +from .conftest import create_widget + + +class TestStatusIndicators: + """Widget/action level tests independent of broker wiring.""" + + def test_indicator_widget_state_and_text(self, qtbot): + widget = StatusIndicatorWidget(text="Ready", state="success") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + widget.set_state("warning") + widget.set_text("Alert") + assert widget._state.value == "warning" + assert widget._text_label.text() == "Alert" + + def test_indicator_action_updates_widget_and_action(self, qtbot): + qt_toolbar = QToolBar() + qtbot.addWidget(qt_toolbar) + + action = StatusIndicatorAction(text="Ready", tooltip="Initial") + action.add_to_toolbar(qt_toolbar, qt_toolbar) + + action.set_tooltip("Updated tooltip") + action.set_text("Running") + + assert action.action.toolTip() == "Updated tooltip" + assert action.widget.toolTip() == "Updated tooltip" # type: ignore[union-attr] + assert action.widget._text_label.text() == "Running" # type: ignore[union-attr] + + +class TestStatusBar: + """Status bar + broker integration using fake redis client (mocked_client).""" + + @pytest.fixture(params=[{}, {"names": ["alpha"]}]) + def status_toolbar(self, qtbot, mocked_client, request): + broker = BECStatusBroker(client=mocked_client) + toolbar = create_widget(qtbot, StatusToolBar, **request.param) + yield toolbar + broker.reset_singleton() + + def test_allowed_names_precreates_placeholder(self, status_toolbar): + status_toolbar.broker.refresh_available = lambda: None + status_toolbar.refresh_from_broker() + + # We parametrize the fixture so one invocation has allowed_names set. + if status_toolbar.allowed_names: + name = next(iter(status_toolbar.allowed_names)) + assert status_toolbar.components.exists(name) + act = status_toolbar.components.get_action(name) + assert isinstance(act, StatusIndicatorAction) + assert act.widget._text_label.text() == name # type: ignore[union-attr] + + def test_on_available_adds_and_removes(self, status_toolbar): + conditions = [ + BeamlineConditionUpdateEntry(name="c1", title="Cond 1", condition_type="test"), + BeamlineConditionUpdateEntry(name="c2", title="Cond 2", condition_type="test"), + ] + status_toolbar.on_available_updated(conditions) + assert status_toolbar.components.exists("c1") + assert status_toolbar.components.exists("c2") + + conditions2 = [ + BeamlineConditionUpdateEntry(name="c1", title="Cond 1", condition_type="test") + ] + status_toolbar.on_available_updated(conditions2) + assert status_toolbar.components.exists("c1") + assert not status_toolbar.components.exists("c2") + + def test_on_status_updated_sets_title_and_message(self, status_toolbar): + status_toolbar.add_status_item("beam", text="Initial", state="default", tooltip=None) + payload = {"name": "beam", "status": "warning", "title": "New Title", "message": "Detail"} + status_toolbar.on_status_updated("beam", payload) + + action = status_toolbar.components.get_action("beam") + assert isinstance(action, StatusIndicatorAction) + assert action.widget._text_label.text() == "New Title" # type: ignore[union-attr] + assert action.action.toolTip() == "Detail" + + def test_on_status_updated_keeps_existing_text_when_no_title(self, status_toolbar): + status_toolbar.add_status_item("beam", text="Keep Me", state="default", tooltip=None) + payload = {"name": "beam", "status": "normal", "message": "Note"} + status_toolbar.on_status_updated("beam", payload) + + action = status_toolbar.components.get_action("beam") + assert isinstance(action, StatusIndicatorAction) + assert action.widget._text_label.text() == "Keep Me" # type: ignore[union-attr] + assert action.action.toolTip() == "Note" From 55146665d24592fb8ac1bb5e21b7aaf30289c33a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 5 Dec 2025 16:41:05 +0100 Subject: [PATCH 160/161] feat(main_window): status bar added to main window bottom left --- .../containers/main_window/main_window.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 2fc59d0e7..4ada1a77d 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -22,6 +22,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.status_bar import StatusToolBar from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import ( BECNotificationBroker, @@ -114,14 +115,11 @@ def _init_status_bar_widgets(self): Prepare the BEC specific widgets in the status bar. """ - # Left: App‑ID label - self._app_id_label = QLabel() - self._app_id_label.setAlignment( - Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter - ) - self.status_bar.addWidget(self._app_id_label) + # Left: Beamline condition status toolbar (auto-fetches all conditions) + self._status_toolbar = StatusToolBar(parent=self, names=None) + self.status_bar.addWidget(self._status_toolbar) - # Add a separator after the app ID label + # Add a separator after the status toolbar self._add_separator() # Centre: Client‑info label (stretch=1 so it expands) @@ -394,13 +392,17 @@ def _setup_menu_bar(self): help_menu.addAction(bec_docs) help_menu.addAction(widgets_docs) help_menu.addAction(bug_report) + help_menu.addSeparator() + self._app_id_action = QAction(self) + self._app_id_action.setEnabled(False) + help_menu.addAction(self._app_id_action) ################################################################################ # Status Bar Addons ################################################################################ def display_app_id(self): """ - Display the app ID in the status bar. + Display the app ID in the Help menu. """ if self.bec_dispatcher.cli_server is None: status_message = "Not connected" @@ -408,7 +410,8 @@ def display_app_id(self): # Get the server ID from the dispatcher server_id = self.bec_dispatcher.cli_server.gui_id status_message = f"App ID: {server_id}" - self._app_id_label.setText(status_message) + if hasattr(self, "_app_id_action"): + self._app_id_action.setText(status_message) @SafeSlot(dict, dict) def display_client_message(self, msg: dict, meta: dict): From d6f5c0e4f94ccd7fcd5fe0f8082a94c3ee6b9d3d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 8 Dec 2025 11:26:23 +0100 Subject: [PATCH 161/161] fix(launch_window): added one more connection for BECStatusBroker to show launcher --- bec_widgets/applications/launch_window.py | 2 +- tests/end-2-end/test_rpc_widgets_e2e.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 05c4f6d9f..4e7eb93e9 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -549,7 +549,7 @@ def _launcher_is_last_widget(self, connections: dict) -> bool: remaining_connections = [ connection for connection in connections.values() if connection.parent_id != self.gui_id ] - return len(remaining_connections) <= 4 + return len(remaining_connections) <= 5 def _turn_off_the_lights(self, connections: dict): """ diff --git a/tests/end-2-end/test_rpc_widgets_e2e.py b/tests/end-2-end/test_rpc_widgets_e2e.py index b513068da..f3a8457bc 100644 --- a/tests/end-2-end/test_rpc_widgets_e2e.py +++ b/tests/end-2-end/test_rpc_widgets_e2e.py @@ -78,15 +78,15 @@ def test_available_widgets(qtbot, connected_client_gui_obj): """This test checks that all widgets that are available via gui.available_widgets can be created and removed.""" gui = connected_client_gui_obj dock_area = gui.bec - # Number of top level widgets, should be 4 - top_level_widgets_count = 12 + # Number of top level widgets, should be 5 + top_level_widgets_count = 13 assert len(gui._server_registry) == top_level_widgets_count names = set(list(gui._server_registry.keys())) - # Number of widgets with parent_id == None, should be 2 + # Number of widgets with parent_id == None, should be 3 widgets = [ widget for widget in gui._server_registry.values() if widget["config"]["parent_id"] is None ] - assert len(widgets) == 2 + assert len(widgets) == 3 # Test all relevant widgets for object_name in gui.available_widgets.__dict__: @@ -122,7 +122,7 @@ def test_available_widgets(qtbot, connected_client_gui_obj): for widget in gui._server_registry.values() if widget["config"]["parent_id"] is None ] - assert len(widgets) == 2 + assert len(widgets) == 3 ############################# ####### Remove widget ####### @@ -154,10 +154,10 @@ def test_available_widgets(qtbot, connected_client_gui_obj): f"is {len(gui._server_registry)} instead of {top_level_widgets_count}. The following " f"widgets are not cleaned up: {set(gui._server_registry.keys()) - names}" ) from exc - # Number of widgets with parent_id == None, should be 2 + # Number of widgets with parent_id == None, should be 3 widgets = [ widget for widget in gui._server_registry.values() if widget["config"]["parent_id"] is None ] - assert len(widgets) == 2 + assert len(widgets) == 3