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 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: diff --git a/bec_widgets/__init__.py b/bec_widgets/__init__.py index 2621e27e0..f88f7db64 100644 --- a/bec_widgets/__init__.py +++ b/bec_widgets/__init__.py @@ -1,4 +1,19 @@ +import os +import sys + +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 +if sys.platform.startswith("linux"): + qt_platform = os.environ.get("QT_QPA_PLATFORM", "") + if qt_platform != "offscreen": + os.environ["QT_QPA_PLATFORM"] = "xcb" + +# Default QtAds configuration +QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) +QtAds.CDockManager.setConfigFlag( + QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True +) + __all__ = ["BECWidget", "SafeSlot", "SafeProperty"] diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 05c4f6d9f..8bae099c9 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -5,28 +5,31 @@ from typing import TYPE_CHECKING, Callable from bec_lib.logger import bec_logger +from bec_qthemes import enable_hover_gradient from qtpy.QtCore import Qt, Signal # type: ignore from qtpy.QtGui import QFontMetrics, QPainter, QPainterPath, QPixmap from qtpy.QtWidgets import ( QApplication, QComboBox, QFileDialog, + QFrame, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QSpacerItem, + QVBoxLayout, QWidget, ) import bec_widgets from bec_widgets.cli.rpc.rpc_register import RPCRegister from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets +from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.container_utils import WidgetContainerUtils from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.name_utils import pascal_to_snake from bec_widgets.utils.plugin_utils import get_plugin_auto_updates -from bec_widgets.utils.round_frame import RoundedFrame from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates @@ -43,7 +46,7 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__) -class LaunchTile(RoundedFrame): +class LaunchTile(QFrame): DEFAULT_SIZE = (250, 300) open_signal = Signal() @@ -56,8 +59,14 @@ def __init__( description: str | None = None, show_selector: bool = False, tile_size: tuple[int, int] | None = None, + gradient: list[str] | None = None, ): - super().__init__(parent=parent, orientation="vertical") + super().__init__(parent=parent) + self.setProperty("skip_settings", True) + self.setProperty("variant", "tile") + self.setAttribute(Qt.WA_StyledBackground, True) + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(5, 5, 5, 5) # Provide a per‑instance TILE_SIZE so the class can compute layout if tile_size is None: @@ -153,6 +162,12 @@ def __init__( """ ) self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter) + if gradient is not None: + enable_hover_gradient(self, colours=gradient) + + def apply_theme(self, theme: str): + """Allow tiles to be theme-aware without custom styling logic.""" + self.update() def _fit_label_to_width(self, label: QLabel, max_width: int, min_pt: int = 10): """ @@ -215,6 +230,7 @@ def __init__( description="Highly flexible and customizable dock area application with modular widgets.", action_button=lambda: self.launch("dock_area"), show_selector=False, + gradient=["#B73665", "#232770"], ) self.available_auto_updates: dict[str, type[AutoUpdates]] = ( @@ -229,6 +245,7 @@ def __init__( action_button=self._open_auto_update, show_selector=True, selector_items=list(self.available_auto_updates.keys()) + ["Default"], + gradient=["#EE0678", "#FF6A00"], ) self.register_tile( @@ -239,6 +256,7 @@ def __init__( description="GUI application with custom UI file.", action_button=self._open_custom_ui_file, show_selector=False, + gradient=["#155799", "#179655"], ) # plugin widgets @@ -257,6 +275,7 @@ def __init__( action_button=self._open_widget, show_selector=True, selector_items=list(self.available_widgets.keys()), + gradient=["#000046", "#1CB5E0"], ) self._update_theme() @@ -275,6 +294,7 @@ def register_tile( action_button: Callable | None = None, show_selector: bool = False, selector_items: list[str] | None = None, + gradient: list[str] | None = None, ): """ Register a tile in the launcher window. @@ -297,6 +317,7 @@ def register_tile( description=description, show_selector=show_selector, tile_size=self.TILE_SIZE, + gradient=gradient, ) tile.setFixedWidth(self.TILE_SIZE[0]) tile.setMinimumHeight(self.TILE_SIZE[1]) @@ -585,6 +606,7 @@ def closeEvent(self, event): import sys app = QApplication(sys.argv) + apply_theme("dark") launcher = LaunchWindow() launcher.show() sys.exit(app.exec()) diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py new file mode 100644 index 000000000..2eccb7006 --- /dev/null +++ b/bec_widgets/applications/main_app.py @@ -0,0 +1,229 @@ +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.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.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, + 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, anim_duration=anim_duration) + 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._current_view_id: str | None = None + 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, profile_namespace="main_workspace", auto_profile_namespace=False + ) + self.ads.setObjectName("MainWorkspace") + 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" + ) + self.add_view( + icon="display_settings", + title="Device Manager", + id="device_manager", + 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") + 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() + + # --- 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, + ) + # 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) + + 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) + + # 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 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 + + 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(show_examples=args.examples) + + 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/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..6354cafe2 --- /dev/null +++ b/bec_widgets/applications/navigation_centre/side_bar.py @@ -0,0 +1,357 @@ +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 = 250, + 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(4) + 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, *, 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 + if emit_signal: + 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 + if emit_signal: + 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/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/developer_view/__init__.py b/bec_widgets/applications/views/developer_view/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bec_widgets/applications/views/developer_view/developer_view.py b/bec_widgets/applications/views/developer_view/developer_view.py new file mode 100644 index 000000000..6f177c752 --- /dev/null +++ b/bec_widgets/applications/views/developer_view/developer_view.py @@ -0,0 +1,60 @@ +from qtpy.QtWidgets import QWidget + +from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget +from bec_widgets.applications.views.view import ViewBase + + +class DeveloperView(ViewBase): + """ + A view for users to write scripts and macros and execute them within the application. + """ + + 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) + + +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_()) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py new file mode 100644 index 000000000..ce7030c3a --- /dev/null +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -0,0 +1,383 @@ +import re + +import markdown +from bec_lib.endpoints import MessageEndpoints +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 + +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.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 + + +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(DockAreaWidget): + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, variant="compact", **kwargs) + + # Promote toolbar above the dock manager provided by the base class + self.toolbar = ModularToolBar(self) + self.init_developer_toolbar() + 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("MonacoEditor") + self.monaco.save_enabled.connect(self._on_save_enabled_update) + 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("PlottingArea") + 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) + 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)) + ) + self._current_script_id: str | None = None + self.script_editor_tab = None + + self._initialize_layout() + + # 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"]) + + 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, + ) + + # 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, + ) + + # 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}, + ) + + # 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.set_layout_ratios(horizontal=[2, 5, 3], vertical=[7, 3]) + + 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_as_button.action.triggered.connect(self.on_save_as) + + 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, scope) + + # 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): + """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 + 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}") + + @SafeSlot() + def on_stop(self): + 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: 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(value, old_script_id) + + 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(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) + 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: + widget.clear_highlighted_lines() + return + line_number = current_lines[0] + widget.clear_highlighted_lines() + widget.set_highlighted_lines(line_number, line_number) + + def cleanup(self): + self.delete_all() + return super().cleanup() + + +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() + _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/applications/views/device_manager_view/__init__.py b/bec_widgets/applications/views/device_manager_view/__init__.py new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..3029adae3 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_view.py @@ -0,0 +1,686 @@ +from __future__ import annotations + +import os +from functools import partial +from typing import List, Literal + +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 qtpy.QtCore import Qt, QThreadPool, QTimer +from qtpy.QtWidgets import ( + QDialog, + QFileDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QTextEdit, + QVBoxLayout, + 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 +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 import ( + DeviceTableView, + DMConfigView, + 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, +) +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 ( + 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: + """ + 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 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 + + +AVAILABLE_RESOURCE_IS_READY = False + + +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 + 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._root_layout.addWidget(self.dock_manager) + + # Device Table View widget + self.device_table_view = DeviceTableView( + self, shared_selection_signal=self._shared_selection + ) + 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(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(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(self.dock_manager, "Ophyd Test View", self) + self.ophyd_test_dock_view.setWidget(self.ophyd_test_view) + + # 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) + + # 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.RightDockWidgetArea, + self.ophyd_test_dock_view, + self.central_dock_area, + ) + # 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.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area + ) + + # 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(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]) + + 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.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,), + ), + ]: + 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): + 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) + + # Load from disk + load = MaterialIconAction( + text_position="under", + 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) + io_bundle.add_action("load") + + # Add safe to disk + 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("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( + text_position="under", + icon_name="cached", + parent=self, + tooltip="Load current config from Redis", + label_text="Get Current Config", + ) + 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( + 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") + + # 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) + + # Reset composed view + reset_composed = MaterialIconAction( + text_position="under", + 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( + 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) + table_bundle.add_action("add_device") + + # Remove device + remove_device = MaterialIconAction( + 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) + table_bundle.add_action("remove_device") + + # Rerun validation + rerun_validation = MaterialIconAction( + text_position="under", + icon_name="checklist", + parent=self, + tooltip="Run device validation with 'connect' on selected devices", + label_text="Validate Connection", + ) + rerun_validation.action.triggered.connect(self._rerun_validation_action) + 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) + + # 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.""" + 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 = 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): + """ + 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 + 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: + 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() + 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 = _yes_no_question( + self, + "Load currently active config", + "Do you really want to discard the current config and reload?", + ) + if reply == QMessageBox.StandardButton.Yes and self.client.device_manager is not None: + self.device_table_view.set_device_config( + self.client.device_manager._get_redis_device_config() + ) + else: + return + + @SafeSlot() + def _update_redis_action(self) -> None | QMessageBox.StandardButton: + """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_composition_to_redis() + + 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") + threadpool.start(comm) + + @SafeSlot() + def _save_to_disk_action(self): + """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() + 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 = 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: + 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.""" + reply = _yes_no_question( + self, + "Clear View", + "You are about to clear the current composed config view, please confirm...", + ) + if reply == QMessageBox.StandardButton.Yes: + self.device_table_view.clear_device_configs() + + # 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.""" + 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): + """Action for the 'remove_device' action to remove a device.""" + self.device_table_view.remove_selected_rows() + + @SafeSlot() + @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, connect) + + ####### Default view has to be done with setting up splitters ######## + 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: + 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 + ): # 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 + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + + def _get_recovery_config_path(self) -> str: + """Get the recovery config path from the log_writer config.""" + # pylint: disable=protected-access + log_writer_config = self.client._service_config.config.get("log_writer", {}) + writer = DeviceConfigWriter(service_config=log_writer_config) + return os.path.abspath(os.path.expanduser(writer.get_recovery_directory())) + + +if __name__ == "__main__": + import sys + from copy import deepcopy + + from bec_lib.bec_yaml_loader import yaml_load + from qtpy.QtWidgets import QApplication + + from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton + + app = QApplication(sys.argv) + w = QWidget() + l = QVBoxLayout() + w.setLayout(l) + apply_theme("dark") + button = DarkModeButton() + l.addWidget(button) + device_manager_view = DeviceManagerView() + l.addWidget(device_manager_view) + # config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml" + # cfg = yaml_load(config_path) + # cfg.update({"device_will_fail": {"name": "device_will_fail", "some_param": 1}}) + + # # config = device_manager_view.client.device_manager._get_redis_device_config() + # device_manager_view.device_table_view.set_device_config(cfg) + w.show() + w.setWindowTitle("Device Manager View") + w.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) 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 new file mode 100644 index 000000000..8c24a9b95 --- /dev/null +++ b/bec_widgets/applications/views/device_manager_view/device_manager_widget.py @@ -0,0 +1,119 @@ +"""Top Level wrapper for device_manager widget""" + +from __future__ import annotations + +import os + +from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy import QtCore, QtWidgets + +from bec_widgets.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 + + +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.setAutoFillBackground(True) + self._overlay_layout = QtWidgets.QVBoxLayout() + self._overlay_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setLayout(self._overlay_layout) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + # Load current config + self.button_load_current_config = QtWidgets.QPushButton("Load Current Config") + icon = material_icon(icon_name="database", size=(24, 24), convert_to_pixmap=False) + self.button_load_current_config.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_current_config) + self.button_load_current_config.clicked.connect(self._load_config_clicked) + # Load config from disk + self.button_load_config_from_file = QtWidgets.QPushButton("Load Config From File") + icon = material_icon(icon_name="folder", size=(24, 24), convert_to_pixmap=False) + self.button_load_config_from_file.setIcon(icon) + self._overlay_layout.addWidget(self.button_load_config_from_file) + self.button_load_config_from_file.clicked.connect(self._load_config_from_file_clicked) + self._overlay_widget.setVisible(True) + + def _load_config_from_file_clicked(self): + """Handle click on 'Load Config From File' button.""" + start_dir = os.path.expanduser("~") + file_path, _ = QtWidgets.QFileDialog.getOpenFileName( + self, caption="Select Config File", dir=start_dir + ) + if file_path: + self._load_config_from_file(file_path) + + def _load_config_from_file(self, file_path: str): + try: + config = yaml_load(file_path) + except Exception as e: + logger.error(f"Failed to load config from file {file_path}. Error: {e}") + return + config_list = [] + for name, cfg in config.items(): + config_list.append(cfg) + config_list[-1]["name"] = name + self.device_manager_view.device_table_view.set_device_config(config_list) + # self.device_manager_view.ophyd_test.on_device_config_update(config) + self.stacked_layout.setCurrentWidget(self.device_manager_view) + + @SafeSlot() + def _load_config_clicked(self): + """Handle click on 'Load Current Config' button.""" + config = self.client.device_manager._get_redis_device_config() + self.device_manager_view.device_table_view.set_device_config(config) + self.stacked_layout.setCurrentWidget(self.device_manager_view) + + +if __name__ == "__main__": # pragma: no cover + import sys + + 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) + 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 + sys.exit(app.exec_()) diff --git a/bec_widgets/applications/views/view.py b/bec_widgets/applications/views/view.py new file mode 100644 index 000000000..635f68b15 --- /dev/null +++ b/bec_widgets/applications/views/view.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from typing import List + +from qtpy.QtCore import QEventLoop, Qt, QTimer +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFormLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSplitter, + 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 + + +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. + + 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 + + ####### 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 +#################################################################################################### + + +# --- 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() diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 84eb99ab2..2052c9b90 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,13 +94,136 @@ 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.""" +class AdvancedDockArea(RPCBase): + @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" = 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). + """ @rpc_call - def remove(self): + def dock_map(self) -> "dict[str, CDockWidget]": """ - Cleanup the BECConnector + 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. + """ + + @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): + """ + 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. + """ + + @property + @rpc_call + def mode(self) -> "str": + """ + None + """ + + @mode.setter + @rpc_call + def mode(self) -> "str": + """ + None """ @@ -143,6 +263,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 @@ -442,6 +582,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 +677,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 +705,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.""" @@ -964,6 +1147,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.""" @@ -1002,6 +1227,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 +1249,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 +1294,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.""" @@ -1079,6 +1340,161 @@ 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, + 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, + 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. + 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). + 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.""" @@ -1433,6 +1849,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 +2406,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): @@ -2396,81 +2836,242 @@ def num_rotation_90(self) -> "Optional[int]": Get or set the number of 90° rotations to apply. """ - @num_rotation_90.setter + @num_rotation_90.setter + @rpc_call + def num_rotation_90(self) -> "Optional[int]": + """ + Get or set the number of 90° rotations to apply. + """ + + @property + @rpc_call + def transpose(self) -> "bool": + """ + Get or set whether the image is transposed. + """ + + @transpose.setter + @rpc_call + def transpose(self) -> "bool": + """ + Get or set whether the image is transposed. + """ + + @rpc_call + def get_data(self) -> "np.ndarray": + """ + Get the data of the image. + Returns: + np.ndarray: The data of the image. + """ + + +class LogPanel(RPCBase): + """Displays a log panel""" + + @rpc_call + def set_plain_text(self, text: str) -> None: + """ + Set the plain text of the widget. + + Args: + text (str): The text to set. + """ + + @rpc_call + def set_html_text(self, text: str) -> None: + """ + Set the HTML text of the widget. + + Args: + text (str): The text to set. + """ + + +class Minesweeper(RPCBase): ... + + +class MonacoDock(RPCBase): + """MonacoDock is a dock widget that contains Monaco editor instances.""" + + @rpc_call + def new( + self, + widget: "QWidget | str", + *, + closable: "bool" = True, + 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, + 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. + 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). + 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 num_rotation_90(self) -> "Optional[int]": + def widget_list(self) -> "list[QWidget]": """ - Get or set the number of 90° rotations to apply. + Return a list of all widgets contained in the dock area. """ - @property @rpc_call - def transpose(self) -> "bool": + def attach_all(self): """ - Get or set whether the image is transposed. + Re-attach floating docks back into the dock manager. """ - @transpose.setter @rpc_call - def transpose(self) -> "bool": + def delete_all(self): """ - Get or set whether the image is transposed. + Delete all docks and their associated widgets. """ @rpc_call - def get_data(self) -> "np.ndarray": + 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": """ - Get the data of the image. - Returns: - np.ndarray: The data of the image. + 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. -class LogPanel(RPCBase): - """Displays a log panel""" + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ @rpc_call - def set_plain_text(self, text: str) -> None: + def print_layout_structure(self) -> "None": """ - Set the plain text of the widget. - - Args: - text (str): The text to set. + Pretty-print the current splitter paths to stdout. """ @rpc_call - def set_html_text(self, text: str) -> None: + def set_central_dock(self, dock: "CDockWidget | QWidget | str") -> "None": """ - Set the HTML text of the widget. + Promote an existing dock to be the dock manager's central widget. Args: - text (str): The text to set. + dock(CDockWidget | QWidget | str): Dock reference to promote. """ -class Minesweeper(RPCBase): ... - - 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. @@ -2481,7 +3082,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. @@ -2490,7 +3091,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. @@ -2499,13 +3109,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. @@ -2514,13 +3124,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. @@ -2531,10 +3141,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. @@ -2545,7 +3155,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. @@ -2554,7 +3164,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. @@ -2563,7 +3173,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. @@ -2572,7 +3182,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. @@ -2582,7 +3192,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. @@ -2590,6 +3200,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 +3494,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 +3918,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 +4151,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 +4192,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 +4224,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 +4255,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.""" @@ -3696,8 +4404,8 @@ def set_position(self, x: "float", y: "float"): """ -class ResetButton(RPCBase): - """A button that resets the scan queue.""" +class ResumeButton(RPCBase): + """A button that continue scan queue.""" @rpc_call def remove(self): @@ -3705,14 +4413,16 @@ def remove(self): Cleanup the BECConnector """ - -class ResumeButton(RPCBase): - """A button that continue scan queue.""" + @rpc_call + def attach(self): + """ + None + """ @rpc_call - def remove(self): + def detach(self): """ - Cleanup the BECConnector + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ @@ -3996,6 +4706,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 +4736,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 +4764,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 +5074,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): @@ -4620,16 +5379,6 @@ def signals(self) -> list[str]: """ -class StopButton(RPCBase): - """A button that stops the current scan.""" - - @rpc_call - def remove(self): - """ - Cleanup the BECConnector - """ - - class TextBox(RPCBase): """A widget that displays text in plain and HTML format""" @@ -4661,6 +5410,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 +5733,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 +5980,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 +6031,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/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/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 6c80dd130..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,147 +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.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, - # "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.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) - # - # 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) @@ -168,18 +391,26 @@ 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) 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_()) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index 48a29fd1c..9c820c7a0 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -77,6 +77,8 @@ class BECConnector: USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"] EXIT_HANDLERS = {} + widget_removed = Signal() + name_established = Signal(str) def __init__( self, @@ -127,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() @@ -204,6 +217,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.emit(self.object_name) + except RuntimeError: + return def _enforce_unique_sibling_name(self): """ @@ -450,6 +467,7 @@ def remove(self): # i.e. Curve Item from Waveform else: self.rpc_register.remove_rpc(self) + self.widget_removed.emit() # Emit the remove signal to notify listeners (eg docks in QtADS) def get_config(self, dict_output: bool = True) -> dict | BaseModel: """ diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index ed58aeb2a..dd94d0c81 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -3,17 +3,18 @@ from datetime import datetime from typing import TYPE_CHECKING -import darkdetect 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 +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.colors import set_theme -from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.error_popups import SafeConnect, SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout +from bec_widgets.utils.widget_io import WidgetHierarchy if TYPE_CHECKING: # pragma: no cover from bec_widgets.widgets.containers.dock import BECDock @@ -27,7 +28,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__( @@ -36,6 +37,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, ): @@ -45,8 +48,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: @@ -56,31 +58,37 @@ 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 ) if not isinstance(self, QObject): raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") - app = QApplication.instance() - if not hasattr(app, "theme"): - # DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault - # Instead, we will set the theme to the system setting on startup - if darkdetect.isDark(): - set_theme("dark") - else: - set_theme("light") - if theme_update: logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") self._connect_to_theme_change() + # 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() - if hasattr(qapp, "theme_signal"): - qapp.theme_signal.theme_updated.connect(self._update_theme) + if hasattr(qapp, "theme"): + SafeConnect(self, qapp.theme.theme_changed, self._update_theme) + @SafeSlot(str) + @SafeSlot() def _update_theme(self, theme: str | None = None): """Update the theme.""" if theme is None: @@ -89,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): """ @@ -100,6 +177,23 @@ 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}") + + 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. + """ + return "" + @SafeSlot() @SafeSlot(str) @rpc_timeout(None) @@ -124,6 +218,70 @@ 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() + + 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.AspectRatioMode.KeepAspectRatio, Qt.QSmoothTransformation + ) + ba = QByteArray() + buf = QBuffer(ba) + buf.open(QIODevice.OpenModeFlag.WriteOnly) + pixmap.save(buf, fmt, quality) + buf.close() + return ba + + 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(): @@ -138,6 +296,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()) diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 9aa40c3ba..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 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 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"): @@ -23,118 +21,35 @@ 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: +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 -def _theme_update_callback(): - """ - Internal callback function to update the theme based on the system theme. - """ - app = QApplication.instance() - # pylint: disable=protected-access - app.theme.theme = app.os_listener._theme.lower() - app.theme_signal.theme_updated.emit(app.theme.theme) - apply_theme(app.os_listener._theme.lower()) - - -def set_theme(theme: Literal["dark", "light", "auto"]): - """ - Set the theme for the application. - - Args: - theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme. - """ - app = QApplication.instance() - bec_qthemes.setup_theme(theme, install_event_filter=False) - - app.theme_signal.theme_updated.emit(theme) - apply_theme(theme) - - if theme != "auto": - return - - if not hasattr(app, "os_listener") or app.os_listener is None: - app.os_listener = OSThemeSwitchListener(_theme_update_callback) - app.installEventFilter(app.os_listener) +def process_all_deferred_deletes(qapp): + qapp.sendPostedEvents(None, QEvent.DeferredDelete) + qapp.processEvents(QEventLoop.AllEvents) def apply_theme(theme: Literal["dark", "light"]): """ - Apply the theme to all pyqtgraph widgets. Do not use this function directly. Use set_theme instead. + Apply the theme via the global theming API. This updates QSS, QPalette, and pyqtgraph globally. """ - app = QApplication.instance() - graphic_layouts = [ - child - for top in app.topLevelWidgets() - for child in top.findChildren(pg.GraphicsLayoutWidget) - ] - - plot_items = [ - item - for gl in graphic_layouts - for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items - if isinstance(item, pg.PlotItem) - ] - - histograms = [ - item - for gl in graphic_layouts - for item in gl.ci.items.keys() # ci is internal pg.GraphicsLayout that hosts all items - if isinstance(item, pg.HistogramLUTItem) - ] - - # Update background color based on the theme - if theme == "light": - background_color = "#e9ecef" # Subtle contrast for light mode - foreground_color = "#141414" - label_color = "#000000" - axis_color = "#666666" - else: - background_color = "#141414" # Dark mode - foreground_color = "#e9ecef" - label_color = "#FFFFFF" - axis_color = "#CCCCCC" - - # update GraphicsLayoutWidget - pg.setConfigOptions(foreground=foreground_color, background=background_color) - for pg_widget in graphic_layouts: - pg_widget.setBackground(background_color) - - # update PlotItems - for plot_item in plot_items: - for axis in ["left", "right", "top", "bottom"]: - plot_item.getAxis(axis).setPen(pg.mkPen(color=axis_color)) - plot_item.getAxis(axis).setTextPen(pg.mkPen(color=label_color)) - - # Change title color - plot_item.titleLabel.setText(plot_item.titleLabel.text, color=label_color) - - # Change legend color - if hasattr(plot_item, "legend") and plot_item.legend is not None: - plot_item.legend.setLabelTextColor(label_color) - # if legend is in plot item and theme is changed, has to be like that because of pg opt logic - for sample, label in plot_item.legend.items: - label_text = label.text - label.setText(label_text, color=label_color) - - # update HistogramLUTItem - for histogram in histograms: - histogram.axis.setPen(pg.mkPen(color=axis_color)) - histogram.axis.setTextPen(pg.mkPen(color=label_color)) - - # now define stylesheet according to theme and apply it - style = bec_qthemes.load_stylesheet(theme) - app.setStyleSheet(style) + process_all_deferred_deletes(QApplication.instance()) + apply_theme_global(theme) + process_all_deferred_deletes(QApplication.instance()) class Colors: diff --git a/bec_widgets/utils/compact_popup.py b/bec_widgets/utils/compact_popup.py index cb5203b8a..af8b48a2d 100644 --- a/bec_widgets/utils/compact_popup.py +++ b/bec_widgets/utils/compact_popup.py @@ -11,6 +11,7 @@ QPushButton, QSizePolicy, QSpacerItem, + QToolButton, QVBoxLayout, QWidget, ) @@ -122,15 +123,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) ) @@ -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/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 diff --git a/bec_widgets/utils/expandable_frame.py b/bec_widgets/utils/expandable_frame.py index 9f65500e0..08a4d95f4 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,10 +32,11 @@ def __init__( super().__init__(parent=parent) self._expanded = expanded - self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain) + self._title_text = f"{title}" + 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) @@ -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/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 e9d92db6f..b480d3a1a 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 @@ -168,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() @@ -185,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""" @@ -392,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() @@ -442,10 +447,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 +494,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) @@ -557,7 +566,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 @@ -598,7 +614,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/utils/guided_tour.py b/bec_widgets/utils/guided_tour.py new file mode 100644 index 000000000..4261c703b --- /dev/null +++ b/bec_widgets/utils/guided_tour.py @@ -0,0 +1,735 @@ +"""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, 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 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 + + +class TourStep(TypedDict): + """Type definition for a tour step.""" + + widget_ref: ( + louie.saferef.BoundMethodWeakref + | weakref.ReferenceType[ + QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]] + ] + | Callable[[], tuple[QWidget | QAction, 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. + + 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 + + # 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, *, 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] = {} + self._tour_steps: List[TourStep] = [] + self._current_index = 0 + self._active = False + + @property + 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() + if isinstance(widget, QWidget): + return widget + return None + + def register_widget( + self, + *, + widget: QWidget | QAction | Callable[[], tuple[QWidget | QAction, str | None]], + text: str = "", + title: str = "", + ) -> str: + """ + Register a widget with help text for tours. + + Args: + 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 or action text). + + Returns: + str: The unique ID for the registered widget. + """ + step_id = str(uuid4()) + # If it's a plain callable + if callable(widget) and not hasattr(widget, "__self__"): + 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: + 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 default_title, + } + 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. + + 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. + + 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()) + + 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] + step_title = step["title"] + + 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 + + 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 + + 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(highlight_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 _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() + + 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 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): + 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 Tour Demo") + central = QWidget() + layout = QVBoxLayout(central) + layout.setSpacing(12) + + 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(self.btn1) + layout.addWidget(self.btn2) + layout.addWidget(self.status_label) + layout.addStretch() + layout.addWidget(self.start_tour_btn) + self.setCentralWidget(central) + + # Guided tour system + self.guided_help = GuidedTour(self) + + # Menus for demonstrating QAction support in menu bars + self._init_menu_bar() + + # Modular toolbar showcasing QAction targets + self._init_toolbar() + + # 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 + 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) + 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/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..9a73cd34c --- /dev/null +++ b/bec_widgets/utils/help_inspector/help_inspector.py @@ -0,0 +1,247 @@ +"""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 +from bec_widgets.utils.widget_io import WidgetHierarchy + +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: 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 + 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 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 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 + if not isinstance(widget, BECWidget): + widget = WidgetHierarchy._get_becwidget_ancestor(widget) + if widget: + if widget is self: + self._toggle_mode(False) + return True + for cb in self._callbacks.values(): + try: + cb(widget) + except Exception as e: + logger.error(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/bec_widgets/utils/list_of_expandable_frames.py b/bec_widgets/utils/list_of_expandable_frames.py new file mode 100644 index 000000000..7ad85a713 --- /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._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/utils/round_frame.py b/bec_widgets/utils/round_frame.py deleted file mode 100644 index 51ec34979..000000000 --- a/bec_widgets/utils/round_frame.py +++ /dev/null @@ -1,157 +0,0 @@ -import pyqtgraph as pg -from qtpy.QtCore import Property -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): - """ - 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. - """ - - def __init__( - self, - parent=None, - content_widget: QWidget = None, - background_color: str = None, - orientation: str = "horizontal", - radius: int = 10, - ): - QFrame.__init__(self, parent) - - self.background_color = background_color - self._radius = radius - - # Apply rounded frame styling - self.setProperty("skip_settings", True) - self.setObjectName("roundedFrame") - - # Create a layout for the frame - if orientation == "vertical": - self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(5, 5, 5, 5) - else: - self.layout = QHBoxLayout(self) - self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin - - # Add the content widget to the layout - if content_widget: - self.layout.addWidget(content_widget) - - # Store reference to the content widget - self.content_widget = content_widget - - # Automatically apply initial styles to the GraphicalLayoutWidget if applicable - self.apply_plot_widget_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 - - self.update_style() - - @Property(int) - def radius(self): - """Radius of the rounded corners.""" - return self._radius - - @radius.setter - def radius(self, value: int): - self._radius = value - self.update_style() - - def update_style(self): - """ - Update the style of the frame based on the background color. - """ - if self.background_color: - self.setStyleSheet( - f""" - QFrame#roundedFrame {{ - background-color: {self.background_color}; - border-radius: {self._radius}; /* Rounded corners */ - }} - """ - ) - 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'). - """ - 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) - - -class ExampleApp(QWidget): # pragma: no cover - def __init__(self): - super().__init__() - self.setWindowTitle("Rounded Plots Example") - - # Main layout - layout = QVBoxLayout(self) - - dark_button = DarkModeButton() - - # Create PlotWidgets - plot1 = pg.GraphicsLayoutWidget() - plot_item_1 = pg.PlotItem() - plot_item_1.plot([1, 3, 2, 4, 6, 5], pen="r") - plot1.plot_item = plot_item_1 - - plot2 = pg.GraphicsLayoutWidget() - plot_item_2 = pg.PlotItem() - 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 - layout.addWidget(dark_button) - layout.addWidget(rounded_plot1) - layout.addWidget(rounded_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) - - -if __name__ == "__main__": # pragma: no cover - app = QApplication([]) - - window = ExampleApp() - window.show() - - app.exec() diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index 4e915cb85..5c0b0955e 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 @@ -10,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, @@ -33,13 +34,39 @@ 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) + 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 == "beside": + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + else: + btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + btn.setText(toolbar_action.label_text) + toolbar.addWidget(btn) + + class NoCheckDelegate(QStyledItemDelegate): """To reduce space in combo boxes by removing the checkmark.""" 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): @@ -84,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): @@ -106,6 +135,11 @@ def cleanup(self): pass +class IconAction(ToolBarAction): + @abstractmethod + def get_icon(self) -> QIcon: ... + + class SeparatorAction(ToolBarAction): """Separator action for the toolbar.""" @@ -113,56 +147,91 @@ def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): toolbar.addSeparator() -class QtIconAction(ToolBarAction): - def __init__(self, standard_icon, tooltip=None, checkable=False, parent=None): +class QtIconAction(IconAction): + 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 -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. 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. """ 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, + 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: 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." @@ -178,7 +247,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): """ @@ -195,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 @@ -221,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. @@ -241,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, @@ -266,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(): @@ -368,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 @@ -395,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)) @@ -411,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): """ @@ -426,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 @@ -440,16 +508,20 @@ 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 + self._menu_ref: weakref.ReferenceType[QMenu] | None = 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) - button.setPopupMode(QToolButton.InstantPopup) + button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) button.setStyleSheet( """ QToolButton { @@ -476,6 +548,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): @@ -522,3 +602,76 @@ 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, + ) + + from bec_widgets.utils.guided_tour import GuidedTour + + 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=widget, text=text, title=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/bec_widgets/utils/toolbars/toolbar.py b/bec_widgets/utils/toolbars/toolbar.py index 21b3c7107..4b10fba84 100644 --- a/bec_widgets/utils/toolbars/toolbar.py +++ b/bec_widgets/utils/toolbars/toolbar.py @@ -6,11 +6,11 @@ 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 -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 @@ -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. @@ -507,7 +530,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/utils/widget_io.py b/bec_widgets/utils/widget_io.py index 22a754d9a..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.""" @@ -465,13 +474,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 @@ -553,6 +568,70 @@ 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 whose closest BECConnector ancestor is the given widget, + including the widget itself if it is a BECConnector. + """ + from bec_widgets.utils import BECConnector + + connectors: list[BECConnector] = [] + if isinstance(widget, BECConnector): + connectors.append(widget) + for child in widget.findChildren(BECConnector): + if WidgetHierarchy._get_becwidget_ancestor(child) is widget: + connectors.append(child) + return connectors + + @staticmethod + def find_ancestor( + widget: QWidget | BECConnector, ancestor_class: Type[TAncestor] | str + ) -> TAncestor | None: + """ + 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. + """ + if widget is None or not shb.isValid(widget): + return None + + try: + from bec_widgets.utils import BECConnector # local import to avoid cycles + + 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 cast(TAncestor, parent) + else: + if isinstance(parent, ancestor_class): + 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 + # Example usage def hierarchy_example(): # pragma: no cover diff --git a/bec_widgets/utils/widget_state_manager.py b/bec_widgets/utils/widget_state_manager.py index 9537097c2..ae5632b86 100644 --- a/bec_widgets/utils/widget_state_manager.py +++ b/bec_widgets/utils/widget_state_manager.py @@ -1,7 +1,9 @@ from __future__ import annotations +import shiboken6 from bec_lib import bec_logger from qtpy.QtCore import QSettings +from qtpy.QtGui import QIcon from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -15,58 +17,89 @@ QWidget, ) +from bec_widgets.utils.widget_io import WidgetHierarchy + logger = bec_logger.logger +PROPERTY_TO_SKIP = ["palette", "font", "windowIcon", "windowIconText", "locale", "styleSheet"] + 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): + def save_state(self, filename: str | None = None, settings: QSettings | None = None): """ Save the state of the widget to an INI file. Args: filename(str): The filename to save the state to. + settings(QSettings): Optional QSettings object to save the state to. """ - if not filename: + if not filename and not settings: filename, _ = QFileDialog.getSaveFileName( self.widget, "Save Settings", "", "INI Files (*.ini)" ) if filename: settings = QSettings(filename, QSettings.IniFormat) self._save_widget_state_qsettings(self.widget, settings) + elif settings: + # If settings are provided, save the state to the provided QSettings object + self._save_widget_state_qsettings(self.widget, settings) + else: + logger.warning("No filename or settings provided for saving state.") - def load_state(self, filename: str = None): + def load_state(self, filename: str | None = None, settings: QSettings | None = None): """ Load the state of the widget from an INI file. Args: filename(str): The filename to load the state from. + settings(QSettings): Optional QSettings object to load the state from. """ - if not filename: + if not filename and not settings: filename, _ = QFileDialog.getOpenFileName( self.widget, "Load Settings", "", "INI Files (*.ini)" ) if filename: settings = QSettings(filename, QSettings.IniFormat) self._load_widget_state_qsettings(self.widget, settings) + elif settings: + # If settings are provided, load the state from the provided QSettings object + self._load_widget_state_qsettings(self.widget, settings) + else: + logger.warning("No filename or settings provided for saving state.") - def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings): + def _save_widget_state_qsettings( + self, widget: QWidget, settings: QSettings, recursive: bool = True + ): """ Save the state of the widget to QSettings. Args: widget(QWidget): The widget to save the state for. settings(QSettings): The QSettings object to save the state to. + recursive(bool): Whether to recursively save the state of child widgets. """ + if widget is None or not shiboken6.isValid(widget): + return + if widget.property("skip_settings") is True: return @@ -76,34 +109,64 @@ def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings): for i in range(meta.propertyCount()): 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 or not prop.isReadable() or not prop.isWritable() or not prop.isStored() # can be extended to fine filter ): 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) - 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() + 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) + 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): + 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 is None or not shiboken6.isValid(widget): + return + if widget.property("skip_settings") is True: return @@ -113,37 +176,76 @@ def _load_widget_state_qsettings(self, widget: QWidget, settings: 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) 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() + child + and shiboken6.isValid(child) + and 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): + 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. - Returns: - str: The full name of the 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"). + + 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/__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..07e878ee6 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -0,0 +1,971 @@ +from __future__ import annotations + +import os +from typing import Callable, Literal, Mapping, Sequence + +from bec_lib import bec_logger +from qtpy.QtCore import QTimer, Signal +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import ( + QApplication, + QDialog, + QInputDialog, + QMessageBox, + QSizePolicy, + QVBoxLayout, + 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 +from bec_widgets.utils.colors import apply_theme +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.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + SETTINGS_KEYS, + 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, + open_default_settings, + open_user_settings, + profile_origin, + profile_origin_display, + read_manifest, + restore_user_from_default, + sanitize_namespace, + set_last_profile, + set_quick_select, + user_profile_candidates, + 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, +) +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 +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 + +logger = bec_logger.logger + +_PROFILE_NAMESPACE_UNSET = object() + +PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")} + + +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", + ] + + # Define a signal for mode changes + mode_changed = Signal(str) + profile_changed = Signal(str) + + def __init__( + self, + parent=None, + mode: Literal["plot", "device", "utils", "user", "creator"] = "creator", + 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, + **kwargs, + ): + 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 + super().__init__( + parent, + default_add_direction=default_add_direction, + title="Advanced Dock Area", + **kwargs, + ) + + # Initialize mode property first (before toolbar setup) + 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() + + # Popups + self.save_dialog = None + self.manage_dialog = None + + # 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, serialize_from_root=True, root_id="AdvancedDockArea" + ) + + # Developer mode state + self._editable = None + # Initialize default editable state based on current lock + self._set_editable(True) # default to editable; will sync toolbar toggle below + + # Sync Developer toggle icon state after initial setup #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 + 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 + namespace = self.profile_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 + ) + if inst_profile and self._profile_exists(inst_profile, namespace): + init_profile = inst_profile + if not init_profile: + 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. + QTimer.singleShot(0, lambda: self._load_initial_profile(init_profile)) + + 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 | 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 = 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: + self.dock_manager.lockDockWidgetFeaturesGlobally() + else: + self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures) + + ################################################################################ + # 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"), + "positioner_box_2D": ( + PositionerBox2D.ICON_NAME, + "Add Device 2D Box", + "PositionerBox2D", + ), + } + UTIL_ACTIONS = { + "queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"), + "status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"), + "progress_bar": ( + RingProgressBar.ICON_NAME, + "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"), + } + + # Create expandable menu actions (original behavior) + def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]): + self.toolbar.components.add_safe( + key, + ExpandableMenuAction( + label=label, + actions={ + k: MaterialIconAction( + icon_name=v[0], tooltip=v[1], filled=True, parent=self + ) + for k, v in mapping.items() + }, + ), + ) + b = ToolbarBundle(key, self.toolbar.components) + b.add_action(key) + self.toolbar.add_bundle(b) + + _build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS) + _build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS) + _build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS) + + # Create flat toolbar bundles for each widget type + def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]): + bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components) + + for action_id, (icon_name, tooltip, widget_type) in mapping.items(): + # Create individual action for each widget type + flat_action_id = f"flat_{action_id}" + self.toolbar.components.add_safe( + flat_action_id, + MaterialIconAction( + icon_name=icon_name, + tooltip=tooltip, + filled=True, + parent=self, + label_text=widget_type, + text_position="under", + ), + ) + bundle.add_action(flat_action_id) + + self.toolbar.add_bundle(bundle) + + _build_flat_bundles("plots", PLOT_ACTIONS) + _build_flat_bundles("devices", DEVICE_ACTIONS) + _build_flat_bundles("utils", UTIL_ACTIONS) + + # Workspace + spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components) + spacer = QWidget(parent=self.toolbar.components.toolbar) + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False)) + spacer_bundle.add_action("spacer") + self.toolbar.add_bundle(spacer_bundle) + + self.toolbar.add_bundle( + workspace_bundle(self.toolbar.components, enable_tools=self._profile_management_enabled) + ) + 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.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.get_action("screenshot").action.setVisible( + self._profile_management_enabled + ) + dark_mode_action = WidgetAction( + widget=self.dark_mode_button, adjust_size=False, 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") + bda.add_action("dark_mode") + # bda.add_action("developer_mode") #TODO temporary disable + self.toolbar.add_bundle(bda) + + self._apply_toolbar_layout() + + # 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] + + # first two items not needed for this part + for key, (_, _, widget_type) in mapping.items(): + 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}", + show_settings_action=True, + ) + ) + else: + act.triggered.connect(lambda _, t=widget_type: self.new(widget=t)) + + _connect_menu("menu_plots") + _connect_menu("menu_devices") + _connect_menu("menu_utils") + + 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(t)) + + _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) + + def _set_editable(self, editable: bool) -> None: + self.lock_workspace = not editable + self._editable = 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) + + ################################################################################ + # 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) + 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 _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] + + 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 _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: + """ + 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()) + 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 to save. If None, prompts the user. + """ + + namespace = self.profile_namespace + + def _profile_exists(profile_name: str) -> bool: + return profile_origin(profile_name, namespace=namespace) != "unknown" + + initial_name = name or "" + quickselect_default = is_quick_select(name, namespace=namespace) 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=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.DialogCode.Accepted: + return + + name = dialog.get_profile_name() + quickselect = dialog.is_quick_select() + 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 + workspace_combo.blockSignals(True) + workspace_combo.insertItem(0, f"{name}-saving") + workspace_combo.setCurrentIndex(0) + workspace_combo.blockSignals(False) + + # Create or update default copy controlled by overwrite flag + 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, 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()) + # 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, 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()) + # 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, namespace=namespace) + + 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, namespace=namespace, instance=self._last_profile_instance_id()) + 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. + + Before switching, persist the current profile to the user copy. + Prefer loading the user copy; fall back to the default copy. + """ + 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 + + 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, 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 + + # Rebuild widgets and restore states + 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) + 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, + ) + + 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, namespace=namespace, instance=self._last_profile_instance_id()) + 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 + namespace = self.profile_namespace + + 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, 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, namespace=namespace) + self.delete_all() + self.load_profile(target) + + @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 + + # 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, + "Delete Profile", + f"Are you sure you want to delete the profile '{name}'?\n\n" + f"This action cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + namespace = self.profile_namespace + delete_profile_files(name, namespace=namespace) + 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 + 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 + combo.blockSignals(True) + combo.clear() + 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) + 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() + 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) + + ################################################################################ + # Mode Switching + ################################################################################ + + @SafeProperty(str) + def mode(self) -> str: + return self._mode + + @mode.setter + def mode(self, new_mode: str): + 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) + 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: + 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, instance=self._last_profile_instance_id()) + self._exit_snapshot_written = True + + def cleanup(self): + """ + Cleanup the dock area. + """ + self.prepare_for_shutdown() + 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() + self.toolbar.cleanup() + super().cleanup() + + +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) + + window.show() + window.resize(800, 1000) + + sys.exit(app.exec()) 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..5620e7d1e --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -0,0 +1,1519 @@ +from __future__ import annotations + +import inspect +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 QApplication, QDialog, QVBoxLayout, QWidget +from shiboken6 import isValid + +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.qt_ads import ( + CDockAreaWidget, + CDockManager, + CDockSplitter, + CDockWidget, +) + +logger = bec_logger.logger + + +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 + 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 + relative_to: CDockWidget | None = None + title_visible: 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 + 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, + 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, + 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. + 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. + 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 floating_state: + self._apply_floating_state_to_dock(dock, floating_state) + 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, + 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, + 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. + 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. + 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, + floating_state=floating_state, + 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, + floating_state=spec.floating_state, + 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 _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, + *, + 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) -> QtAds.ads.TitleBarButton | None: + """Translate a user-friendly button name into an ADS TitleBarButton enum.""" + normalized = (name or "").lower().replace("-", "_").replace(" ", "_") + 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 | QtAds.ads.TitleBarButton, bool] + | Sequence[str | QtAds.ads.TitleBarButton] + | str + | QtAds.ads.TitleBarButton + | None + ), + ) -> dict[QtAds.ads.TitleBarButton, bool]: + """Normalize button visibility specifications into an enum mapping.""" + if spec is None: + return {} + + result: dict[QtAds.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, QtAds.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, + 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, + 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. + 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). + 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, + floating_state=floating_state, + 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, + floating_state=floating_state, + 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 _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 {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._iter_all_docks() + + 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 new file mode 100644 index 000000000..87f039686 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -0,0 +1,1066 @@ +""" +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 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 +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 + +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 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: + return 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 + 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: + """ + 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 + + 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 as e: + logger.error(f"Could not determine plugin package name: {e}") + + for candidate in candidates: + if candidate.is_dir(): + return str(candidate) + return None + + +def _settings_profiles_root() -> str: + """ + 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 = ( + 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 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 _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 _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``. + + 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, 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 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" + 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, 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``. + + 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": + return _plugin_display_name() + if origin == "settings": + return "User" + return None + + +def delete_profile_files(name: str, namespace: str | None = None) -> bool: + """ + Delete the profile files from the writable settings directories. + + 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. + """ + read_only = is_profile_read_only(name, namespace) + + removed = False + # 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 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 + + +SETTINGS_KEYS = { + "geom": "mainWindow/Geometry", + "state": "mainWindow/State", + "ads_state": "mainWindow/DockingState", + "manifest": "manifest/widgets", + "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(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]] = {} + 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 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, namespace) + if not os.path.exists(dst_user): + os.makedirs(os.path.dirname(dst_user), exist_ok=True) + shutil.copyfile(src, dst_user) + s = open_user_settings(name, namespace) + if s.value(SETTINGS_KEYS["created_at"], "") == "": + s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + + settings_names |= set(read_only_sources.keys()) + + # Return union of all discovered names + 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``. + + Returns: + QSettings: Settings instance targeting the default profile file. + """ + return QSettings(default_profile_path(name, namespace), 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: + """ + 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 _last_profile_key(namespace: str | None, instance: str | None = None) -> str: + """ + Build the QSettings key used to store the last profile per namespace and + optional instance id. + + Args: + namespace (str | None): Namespace label. + + Returns: + str: Scoped key string. + """ + ns = sanitize_namespace(namespace) + key = SETTINGS_KEYS["last_profile"] + 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, + 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() + 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, 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(key, name) + else: + s.remove(key) + + +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. + """ + + 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()) + 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)) + 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() + + +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): + 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"), + "widget_class": settings.value("widget_class"), + "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() + return items + + +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 + 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, 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, 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, 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(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) + except Exception: + return now_iso_utc() + + +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 + + 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): + """Pydantic model capturing profile metadata surfaced in the UI.""" + + 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, namespace: str | None = None) -> ProfileInfo: + """ + 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. + """ + 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"} + 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" + 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, namespace), + 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, 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_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(s) + + +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(s) 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..19329c34b --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +from typing import Callable, Literal + +from qtpy.QtCore import Qt +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import ( + 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.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.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.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._original: QPixmap | None = pixmap if (pixmap and not pixmap.isNull()) else None + + layout = QVBoxLayout(self) + + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_label.setMinimumSize(360, 240) + self.image_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.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): + """ + 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("") + 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..36357a17e --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +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 +from qtpy.QtWidgets import ( + QAbstractItemView, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QSplitter, + QStyledItemDelegate, + QTableWidget, + QTableWidgetItem, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget, SafeSlot +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, + is_quick_select, + list_profiles, + load_profile_screenshot, + set_quick_select, +) + +logger = bec_logger.logger + + +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.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"): + 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(namespace=self.profile_namespace): + 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, namespace=self.profile_namespace) + + # 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, namespace=self.profile_namespace) + 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, namespace=self.profile_namespace) + 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: + 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, 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() + 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, namespace=self.profile_namespace) + 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, namespace=self.profile_namespace) + 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(namespace=self.profile_namespace) + 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, namespace=self.profile_namespace) + 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/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..58bb8cbe9 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from typing import Callable + +from qtpy.QtCore import Qt +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 list_quick_profiles + + +class ProfileComboBox(QComboBox): + """Custom combobox that displays icons for read-only profiles.""" + + def __init__(self, parent=None): + super().__init__(parent) + 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: + self._quick_provider = provider + + 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 = active_profile or self.currentText() + self.blockSignals(True) + self.clear() + + quick_profiles = self._quick_provider() + 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.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" + if profile not in quick_set: + font = QFont(self.font()) + font.setItalic(True) + font.setBold(True) + 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.ItemDataRole.ToolTipRole) + self.setCurrentIndex(idx) + elif profile not in quick_set: + self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole) + + # Restore selection if possible + index = self.findText(current_text) + if index >= 0: + 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, enable_tools: bool = True) -> 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. + """ + # Workspace combo + combo = ProfileComboBox(parent=components.toolbar) + combo.setVisible(enable_tools) + components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False)) + + components.add_safe( + "save_workspace", + MaterialIconAction( + icon_name="save", + tooltip="Save Current Workspace", + checkable=False, + parent=components.toolbar, + ), + ) + components.get_action("save_workspace").action.setVisible(enable_tools) + + components.add_safe( + "reset_default_workspace", + MaterialIconAction( + icon_name="undo", + tooltip="Refresh Current Workspace", + checkable=False, + parent=components.toolbar, + ), + ) + 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 + ), + ) + components.get_action("manage_workspaces").action.setVisible(enable_tools) + + bundle = ToolbarBundle("workspace", components) + bundle.add_action("workspace_combo") + bundle.add_action("save_workspace") + bundle.add_action("reset_default_workspace") + bundle.add_action("manage_workspaces") + return bundle + + +class WorkspaceConnection(BundleConnection): + """ + Connection class for workspace actions in AdvancedDockArea. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + self.bundle_name = "workspace" + self.components = components + self.target_widget = target_widget + if not hasattr(self.target_widget, "lock_workspace"): + raise AttributeError("Target widget must implement 'lock_workspace'.") + self._connected = False + + def connect(self): + self._connected = True + # Connect the action to the target widget's method + 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 + ) + + 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 + 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 + ) + + 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() + def _reset_workspace_to_default(self): + """ + Refreshes the current workspace. + """ + self.target_widget.restore_user_profile_from_default() 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/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index ad0b9ae1c..f062e3d8d 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 @@ -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 @@ -57,13 +66,16 @@ def __init__(self, parent=None, title="", indentation=10, show_add_button=False) 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.ToolButtonStyle.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), convert_to_pixmap=False)) header_layout.addWidget(self.header_add_button) self.main_layout.addLayout(header_layout) @@ -95,20 +107,18 @@ def _update_appearance(self): # Get theme colors palette = get_theme_palette() - text_color = palette.text().color().name() self.header_button.setStyleSheet( - f""" - QPushButton {{ + """ + QPushButton { font-weight: bold; text-align: left; margin: 0; padding: 0px; border: none; background: transparent; - color: {text_color}; icon-size: 20px 20px; - }} + } """ ) 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/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 new file mode 100644 index 000000000..2546eb351 --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py @@ -0,0 +1,382 @@ +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 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(ExplorerDelegate): + """Custom delegate to show action buttons on hover for macro functions""" + + def __init__(self, parent=None): + super().__init__(parent) + 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""" + self.macro_actions.append(action) + + def clear_actions(self) -> None: + """Remove all actions""" + self.macro_actions.clear() + + 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): + 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 + return self.macro_actions + + +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 _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() + 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: + file_item = self._create_file_item(py_file) + if file_item: + self.model.appendRow(file_item) + + 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 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() + + 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..68ff10353 100644 --- a/bec_widgets/widgets/containers/explorer/script_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/script_tree_widget.py @@ -2,32 +2,29 @@ from pathlib import Path from bec_lib.logger import bec_logger -from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal -from qtpy.QtGui import QAction, QPainter -from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget +from 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, parent=None): - super().__init__(parent) - self.hovered_index = QModelIndex() - self.file_actions: list[QAction] = [] - self.dir_actions: list[QAction] = [] - self.button_rects: list[QRect] = [] - self.current_file_path = "" + def __init__(self, tree_widget): + super().__init__(tree_widget) + self.file_actions = [] + self.dir_actions = [] - 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) @@ -36,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: list[QAction]): - """Draw action buttons on the right side""" - button_size = 18 - margin = 4 - spacing = 2 - - # Calculate total width needed for all buttons - total_width = len(actions) * button_size + (len(actions) - 1) * spacing - - # Clear previous button rects and create new ones - self.button_rects.clear() - - # Calculate starting position (right side of the item) - start_x = option.rect.right() - total_width - margin - current_x = start_x - - painter.save() - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get theme colors for better integration - palette = get_theme_palette() - button_bg = palette.button().color() - button_bg.setAlpha(150) # Semi-transparent - - for action in actions: - if not action.isVisible(): - continue - - # Calculate button position - button_rect = QRect( - current_x, - option.rect.top() + (option.rect.height() - button_size) // 2, - button_size, - button_size, - ) - self.button_rects.append(button_rect) - - # Draw button background - painter.setBrush(button_bg) - painter.setPen(palette.mid().color()) - painter.drawRoundedRect(button_rect, 3, 3) - - # Draw action icon - icon = action.icon() - if not icon.isNull(): - icon_rect = button_rect.adjusted(2, 2, -2, -2) - icon.paint(painter, icon_rect) - - # Move to next button position - current_x += button_size + spacing - - painter.restore() - - def editorEvent(self, event, model, option, index): - """Handle mouse events for action buttons""" - # Early return if not a left click - if not ( - event.type() == event.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton - ): - return super().editorEvent(event, model, option, index) - - # Early return if not a proxy model + 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): @@ -229,12 +118,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; @@ -286,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) @@ -357,11 +252,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/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 7edaff7a4..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 @@ -19,9 +20,8 @@ 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 from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import ( BECNotificationBroker, @@ -35,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): @@ -44,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() @@ -357,7 +349,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) @@ -374,18 +366,19 @@ 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 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) @@ -448,7 +441,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: @@ -456,21 +449,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: - child.cleanup() - 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() diff --git a/bec_widgets/widgets/containers/qt_ads/__init__.py b/bec_widgets/widgets/containers/qt_ads/__init__.py new file mode 100644 index 000000000..aa837994c --- /dev/null +++ b/bec_widgets/widgets/containers/qt_ads/__init__.py @@ -0,0 +1 @@ +from PySide6QtAds import * diff --git a/bec_widgets/widgets/containers/qt_ads/__init__.pyi b/bec_widgets/widgets/containers/qt_ads/__init__.pyi new file mode 100644 index 000000000..dfc1232f4 --- /dev/null +++ b/bec_widgets/widgets/containers/qt_ads/__init__.pyi @@ -0,0 +1,989 @@ +from __future__ import annotations + +import collections +import enum +import typing + +from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import Signal + +from bec_widgets.widgets.containers.qt_ads import ads +from bec_widgets.widgets.containers.qt_ads.ads import * + +# pylint: disable=unused-argument,invalid-name, missing-function-docstring, super-init-not-called + +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: ... 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/control/buttons/button_abort/button_abort.py b/bec_widgets/widgets/control/buttons/button_abort/button_abort.py index c14bb062e..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, @@ -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/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 7d0456ecc..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) @@ -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 78cd5fa21..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 @@ -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 ( @@ -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) @@ -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) @@ -259,7 +261,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 37bfc90b8..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 @@ -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 ( @@ -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) @@ -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) @@ -315,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, @@ -328,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: @@ -478,7 +481,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_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/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index b80227beb..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] """ @@ -202,10 +186,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/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/_util.py b/bec_widgets/widgets/control/device_manager/components/_util.py new file mode 100644 index 000000000..fb1f69935 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/_util.py @@ -0,0 +1,53 @@ +import json +from typing import Any, Callable, Generator, Iterable, TypeVar + +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 ( + MIME_DEVICE_CONFIG, + SORT_KEY_ROLE, +) + +_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 + + +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 + 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() + + +class SharedSelectionSignal(QObject): + proc = Signal(str) 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/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..96759d7b2 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group.py @@ -0,0 +1,230 @@ +from textwrap import dedent +from typing import NamedTuple +from uuid import uuid4 + +from bec_qthemes import material_icon +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 +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, +) +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 = ( + "Device defined with multiple names! Please check:\n " + "\n ".join(spec.names) + if len(spec.names) > 1 + else "" + ) + source_warning = ( + "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.setFrameStyle(0) + + self._layout = QVBoxLayout() + self._layout.setContentsMargins(2, 2, 2, 2) + 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): + + selected_devices = Signal(list) + + def __init__( + 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] = {} + for device in data: + self._add_item(device) + self.device_list.sortItems() + self.setMinimumSize(self.device_list.sizeHint()) + self._update_num_included() + + 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) + 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, + ) + + @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): + if uuid != self._shared_selection_uuid: + self.device_list.clearSelection() + + 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 __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..bea0a1c34 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_group_ui.py @@ -0,0 +1,56 @@ +from typing import TYPE_CHECKING + +from qtpy.QtCore import QMetaObject, Qt +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 ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + +if TYPE_CHECKING: + from .available_device_group import AvailableDeviceGroup + + +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] + + def mimeData(self, items): + return mimedata_from_configs(item.data(CONFIG_DATA_ROLE) for item in items) + + +class Ui_AvailableDeviceGroup(object): + 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) + + 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.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) + 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.py b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py new file mode 100644 index 000000000..93e810156 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources.py @@ -0,0 +1,128 @@ +from random import randint +from typing import Any, Iterable +from uuid import uuid4 + +from qtpy.QtCore import QItemSelection, Signal # type: ignore +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._util import ( + SharedSelectionSignal, + 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, +) +from bec_widgets.widgets.control.device_manager.components.constants import CONFIG_DATA_ROLE + + +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) + 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") + 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(): + 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]): + item, widget = self.device_groups_list.add_item( + 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()) + # 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) + 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_from_groups()) + 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() + + 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 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), used + ) + + @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..05701864a --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/available_device_resources_ui.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import itertools + +from qtpy.QtCore import QMetaObject, Qt +from qtpy.QtWidgets import ( + QAbstractItemView, + QComboBox, + QGridLayout, + QLabel, + QLineEdit, + QListView, + QListWidget, + QListWidgetItem, + QSizePolicy, + QVBoxLayout, +) + +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, +) +from bec_widgets.widgets.control.device_manager.components.constants import ( + CONFIG_DATA_ROLE, + MIME_DEVICE_CONFIG, +) + + +class _ListOfDeviceGroups(ListOfExpandableFrames[AvailableDeviceGroup]): + + 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 = (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): + return [MIME_DEVICE_CONFIG] + + def mimeData(self, items): + return mimedata_from_configs( + itertools.chain.from_iterable(item.data(CONFIG_DATA_ROLE) for item in items) + ) + + +class Ui_availableDeviceResources(object): + def setupUi(self, availableDeviceResources): + if not availableDeviceResources.objectName(): + availableDeviceResources.setObjectName("availableDeviceResources") + self.verticalLayout = QVBoxLayout(availableDeviceResources) + self.verticalLayout.setObjectName("verticalLayout") + + self._add_toolbar() + + # Main area with search and filter using a grid layout + self.search_layout = QVBoxLayout() + self.grid_layout = QGridLayout() + + self.grouping_selector = QComboBox() + 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 + ) + 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(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) + 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()) + + 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.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.add_bundle(io_bundle) + self.toolbar.show_bundles(["IO"]) 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..145d21109 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/available_device_resources/device_resource_backend.py @@ -0,0 +1,140 @@ +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, plugins_installed + +logger = bec_logger.logger + +# use the last n recovery files +_N_RECOVERY_FILES = 3 +_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() + ) + + +class _ConfigFileBackend(DeviceResourceBackend): + def __init__(self) -> None: + 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:] + 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): + files = glob("*.yaml", root_dir=dir, recursive=True) + 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 { + 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), 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} 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..b3f720511 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/constants.py @@ -0,0 +1,72 @@ +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" + +# 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": "\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 b541916b6..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 @@ -4,114 +4,327 @@ import copy import json +import textwrap +from contextlib import contextmanager +from functools import partial +from typing import TYPE_CHECKING, Any, Iterable, List, Literal +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 +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 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 ( + 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 + 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 +# +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.""" - @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): + 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.ToolTip: + if event.type() != QtCore.QEvent.Type.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) + 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.""" +class CustomDisplayDelegate(DictToolTipDelegate): + _paint_test_role = Qt.ItemDataRole.DisplayRole - 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 - ) - self._icon_unchecked = material_icon( - "check_box_outline_blank", size=QtCore.QSize(16, 16), color=colors.default - ) + def displayText(self, value: Any, locale: QtCore.QLocale | QtCore.QLocale.Language) -> str: + return "" - def apply_theme(self, theme: str | None = None): - colors = get_accent_colors() - self._icon_checked.setColor(colors.default) - self._icon_unchecked.setColor(colors.default) - - def paint(self, painter, option, index): - value = index.model().data(index, QtCore.Qt.CheckStateRole) - if value is None: - super().paint(painter, option, index) - return - - # Choose icon based on state - pixmap = self._icon_checked if value == QtCore.Qt.Checked else self._icon_unchecked + 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 - # Draw icon centered - rect = option.rect - pix_rect = pixmap.rect() - pix_rect.moveCenter(rect.center()) - painter.drawPixmap(pix_rect.topLeft(), pixmap) + def _do_custom_paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QModelIndex, + value: Any, + ): ... - def editorEvent(self, event, model, option, index): - if event.type() != QtCore.QEvent.MouseButtonRelease: - return False - current = model.data(index, QtCore.Qt.CheckStateRole) - new_state = QtCore.Qt.Unchecked if current == QtCore.Qt.Checked else QtCore.Qt.Checked - return model.setData(index, new_state, QtCore.Qt.CheckStateRole) + 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) + super().paint(painter, option, index) + painter.save() + self._do_custom_paint(painter, option, index, value) + painter.restore() -class WrappingTextDelegate(DictToolTipDelegate): - """Custom delegate for wrapping text in table cells.""" +class WrappingTextDelegate(CustomDisplayDelegate): + """A lightweight delegate that wraps text without expensive size recalculation.""" - def paint(self, painter, option, index): - text = index.model().data(index, QtCore.Qt.DisplayRole) + 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 + 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 super().paint(painter, option, index) - + return painter.save() painter.setClipRect(option.rect) - text_option = QtCore.Qt.TextWordWrap | QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop - painter.drawText(option.rect.adjusted(4, 2, -4, -2), text_option, text) + + # Use cached layout if available + cache_key = (text, option.rect.width()) + layout = self._cache.get(cache_key) + if layout is None: + layout = self._compute_layout(text, option) + self._cache[cache_key] = layout + + # Draw text + painter.setPen(option.palette.text().color()) + layout.draw(painter, option.rect.topLeft()) painter.restore() - def sizeHint(self, option, index): - text = str(index.model().data(index, QtCore.Qt.DisplayRole) or "") - # if not text: - # return super().sizeHint(option, index) + 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) + 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.. + 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 + 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: 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) + @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() + # 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.""" + view = self._parent + proxy = view.model() + option = QtWidgets.QStyleOptionViewItem() + view.initViewItemOption(option) + for row in range(proxy.rowCount()): + max_height = 18 + for column in self.wrapping_text_columns: + index = proxy.index(row, column) + 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.""" + + _paint_test_role = USER_CHECK_DATA_ROLE + + 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) + 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() + _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: 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: 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) + new_state = ( + Qt.CheckState.Unchecked if current == Qt.CheckState.Checked else Qt.CheckState.Checked + ) + return model.setData(index, new_state, USER_CHECK_DATA_ROLE) - # 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) +class DeviceValidatedDelegate(CustomDisplayDelegate): + """Custom delegate for displaying validated device configurations.""" - 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) + 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) + self._icons = { + 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): + colors = get_accent_colors() + _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: 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()) + painter.drawPixmap(pix_rect.topLeft(), pixmap) class DeviceTableModel(QtCore.QAbstractTableModel): @@ -121,62 +334,86 @@ 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): + # 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: DeviceTableModel | None = None): super().__init__(parent) - self._device_config = device_config or [] + 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", - "enabled", - "readOnly", + "onFailure", "deviceTags", "description", + "enabled", + "readOnly", + "softwareTrigger", ] self._checkable_columns_enabled = {"enabled": True, "readOnly": True} + self._device_model_schema = Device.model_json_schema() ############################################### - ########## Overwrite custom Qt methods ######## + ########## Override custom Qt methods ######### ############################################### - def rowCount(self, parent=QtCore.QModelIndex()) -> int: + 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: + if section == 9: # softwareTrigger + return "softTrig" 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 {} 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 == 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._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 == QtCore.Qt.DisplayRole: - if key in ("enabled", "readOnly"): + if role == Qt.ItemDataRole.DisplayRole: + 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 == QtCore.Qt.CheckStateRole and key in ("enabled", "readOnly"): - return QtCore.Qt.Checked if value else QtCore.Qt.Unchecked - if role == QtCore.Qt.TextAlignmentRole: - if key in ("enabled", "readOnly"): - return QtCore.Qt.AlignCenter - return QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - if role == QtCore.Qt.FontRole: + 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", "softwareTrigger"): + return Qt.AlignmentFlag.AlignCenter + return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + if role == Qt.ItemDataRole.FontRole: font = QtGui.QFont() return font return None @@ -184,18 +421,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()] - if key in ("enabled", "readOnly"): - base_flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + base_flags = super().flags(index) | ( + Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled + ) + + if key in ("enabled", "readOnly", "softwareTrigger"): if self._checkable_columns_enabled.get(key, True): - return base_flags | QtCore.Qt.ItemIsUserCheckable + return base_flags | 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: + def setData(self, index, value, role=int(Qt.ItemDataRole.EditRole)) -> bool: """ Method to set the data of the table. @@ -210,106 +450,172 @@ def setData(self, index, value, role=QtCore.Qt.EditRole) -> bool: if not index.isValid(): return False key = self.headers[index.column()] - row = index.row() - - if key in ("enabled", "readOnly") and role == QtCore.Qt.CheckStateRole: + if 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[row][key] = value == QtCore.Qt.Checked - self.dataChanged.emit(index, index, [QtCore.Qt.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 + #################################### + ############ Drag and Drop ######### + #################################### + + def mimeTypes(self) -> List[str]: + return [*super().mimeTypes(), MIME_DEVICE_CONFIG] + + def supportedDropActions(self): + return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction + + def dropMimeData(self, data, action, row, column, parent): + if action not in [Qt.DropAction.CopyAction, Qt.DropAction.MoveAction]: + return False + if (raw_data := data.data(MIME_DEVICE_CONFIG)) is None: + return False + self.add_device_configs(json.loads(raw_data.toStdString())) + return True + #################################### ############ Public methods ######## #################################### - def get_device_config(self) -> list[dict]: - """Return the current device config (with checkbox updates applied).""" - return self._device_config + def get_device_config(self) -> list[dict[str, Any]]: + """Method to get the device configuration.""" + return copy.deepcopy(self._device_config) - def set_checkbox_enabled(self, column_name: str, enabled: bool): - """ - Enable/Disable the checkbox column. + 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 - 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 _name_exists_in_config(self, name: str, exists: bool): + if (name in self.device_names()) == exists: + return True + return not exists - def set_device_config(self, device_config: list[dict]): + def add_device_configs(self, device_configs: _DeviceCfgIter): """ - Replace the device config. + Add devices to the model. Args: - device_config (list[dict]): The new device config to set. + device_configs (_DeviceCfgList): An iterable of device configurations to add. """ - self.beginResetModel() - self._device_config = list(device_config) - self.endResetModel() - - @SafeSlot(dict) - def add_device(self, device: dict): + already_in_list = [] + added_configs = [] + for cfg in device_configs: + if self._name_exists_in_config(name := cfg.get("name", ""), True): + 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)) + added_configs.append(cfg) + self.endInsertRows() + self.configs_changed.emit(device_configs, True) + + def remove_device_configs(self, device_configs: _DeviceCfgIter): """ - Add an extra device to the device config at the bottom. + Remove devices from the model. Args: - device (dict): The device configuration to add. + device_configs (_DeviceCfgList): An iterable of device configurations to remove. """ - row = len(self._device_config) - self.beginInsertRows(QtCore.QModelIndex(), row, row) - self._device_config.append(device) - self.endInsertRows() + 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.") + return None - @SafeSlot(int) - def remove_device_by_row(self, row: int): + @contextmanager + def _remove_row(self, row: int): + self.beginRemoveRows(QtCore.QModelIndex(), row, row) + try: + yield row + finally: + self.endRemoveRows() + + def set_device_config(self, device_configs: _DeviceCfgIter): """ - Remove one device row by index. This maps to the row to the source of the data model + Replace the device config. Args: - row (int): The index of the device row to remove. + device_config (Iterable[dict[str,Any]]): An iterable of device configurations to set. """ - if 0 <= row < len(self._device_config): - self.beginRemoveRows(QtCore.QModelIndex(), row, row) - self._device_config.pop(row) - self.endRemoveRows() + 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) - @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) + self.beginResetModel() + self._device_config.clear() + self.endResetModel() + self.configs_changed.emit(self._device_config, False) - @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. + device_name (str): The name of the device. + status (int): The new status of the device. """ - for row, device in enumerate(self._device_config): - if device.get("name") == name: - self.remove_device_by_row(row) + if isinstance(status, int): + status = ValidationStatus(status) + 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._device_config): + 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, [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""" + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.setAcceptDrops(True) + 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 @@ -317,50 +623,80 @@ 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) + + 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() - 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 + model: DeviceTableModel = self.model().sourceModel() # access underlying model + self._confirm_and_remove_rows(model, self._get_source_rows(proxy_indexes)) - # 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 - ] + def _get_source_rows(self, proxy_indexes: list[QModelIndex]) -> list[QModelIndex]: + """ + Map proxy model indices to source model row indices. - 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 + Args: + proxy_indexes (list[QModelIndex]): List of proxy model indices. - def _confirm_and_remove_rows(self, model: DeviceTableModel, source_rows: list[int]) -> bool: + Returns: + list[int]: List of source model row indices. + """ + proxy_rows = sorted({idx for idx in proxy_indexes}, reverse=True) + return list(set(self.model().mapToSource(idx) for idx in proxy_rows)) + + 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. """ - cfg = model.get_device_config() - names = [str(cfg[r].get("name", "")) 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] + 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 - 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) + 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") + 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: - model.remove_devices_by_rows(source_rows) - # TODO add signal for removed devices + if res == QMessageBox.StandardButton.Ok: return True return False @@ -372,7 +708,18 @@ 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, 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) + + def sourceModel(self) -> DeviceTableModel: + return super().sourceModel() # type: ignore def hide_rows(self, row_indices: list[int]): """ @@ -384,6 +731,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. @@ -422,7 +777,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: @@ -432,28 +787,68 @@ def filterAcceptsRow(self, source_row: int, source_parent) -> bool: return True return False + def flags(self, index): + return super().flags(index) | 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.""" + # 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 - devices_removed = QtCore.Signal(list) - 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.layout = QtWidgets.QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.setSpacing(4) + 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) + 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.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""" @@ -489,143 +884,246 @@ 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.""" # 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) - self.wrap_delegate = WrappingTextDelegate(self.table) + colors = get_accent_colors() + self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors) 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.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.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors) + 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) # 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) + self.table.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight) + self.table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.table.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) # 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.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(0, QHeaderView.ResizeMode.Fixed) # ValidationStatus + 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.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(70) + header.setMinimumSectionSize(25) header.setDefaultSectionSize(90) - - # Enable resizing of column - header.sectionResized.connect(self.on_table_resized) + 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(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)) + # 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) + + def remove_selected_rows(self): + self.table.delete_selected() - def device_config(self) -> list[dict]: + def get_device_config(self) -> list[dict[str, Any]]: """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): - """Handle changes to the table column resizing.""" - if column != len(self.model.headers) - 1: + 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 _autosize_columns(self): + if self._model.rowCount() == 0: return + for col in (1, 2, 3): + self.table.resizeColumnToContents(col) - for row in range(self.table.model().rowCount()): - index = self.table.model().index(row, column) - delegate = self.table.itemDelegate(index) - option = QtWidgets.QStyleOptionViewItem() - height = delegate.sizeHint(option, index).height() - self.table.setRowHeight(row, height) + @SafeSlot(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 + ) -> None: + """ + Handle selection changes in the device table. + + Args: + selected (QtCore.QItemSelection): The selected items. + deselected (QtCore.QItemSelection): The deselected items. + """ + self._shared_selection_signal.proc.emit(self._shared_selection_uuid) + if not (selected_configs := list(self.table.selected_configs())): + return + self.selected_devices.emit(selected_configs) ###################################### ##### Ext. Slot API ################# ###################################### @SafeSlot(list) - def set_device_config(self, config: list[dict]): + def set_device_config(self, device_configs: _DeviceCfgIter): """ Set the device config. Args: - config (list[dict]): The device config to set. + config (Iterable[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(list) + def add_device_configs(self, device_configs: _DeviceCfgIter): """ - 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): + @SafeSlot(list) + def remove_device_configs(self, device_configs: _DeviceCfgIter): """ - 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) - return + self._model.remove_configs_by_name([device_name]) + + @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_names()) + 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() + 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.show() + window.resize(1920, 1200) + 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 new file mode 100644 index 000000000..245080f32 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_config_view.py @@ -0,0 +1,100 @@ +"""Module with a config view for the device manager.""" + +from __future__ import annotations + +import traceback + +import yaml +from bec_lib.logger import bec_logger +from qtpy import QtCore, QtWidgets + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +logger = bec_logger.logger + + +class DMConfigView(BECWidget, QtWidgets.QWidget): + def __init__(self, parent=None, client=None): + super().__init__(client=client, parent=parent, theme_update=True) + self.stacked_layout = QtWidgets.QStackedLayout() + self.stacked_layout.setContentsMargins(0, 0, 0, 0) + self.stacked_layout.setSpacing(0) + self.setLayout(self.stacked_layout) + + # Monaco widget + self.monaco_editor = MonacoWidget() + self._customize_monaco() + self.stacked_layout.addWidget(self.monaco_editor) + + self._overlay_widget = QtWidgets.QLabel(text="Select single device to show config") + self._customize_overlay() + self.stacked_layout.addWidget(self._overlay_widget) + self.stacked_layout.setCurrentWidget(self._overlay_widget) + + def _customize_monaco(self): + + self.monaco_editor.set_language("yaml") + self.monaco_editor.set_vim_mode_enabled(False) + self.monaco_editor.set_minimap_enabled(False) + # self.monaco_editor.setFixedHeight(600) + self.monaco_editor.set_readonly(True) + self.monaco_editor.editor.set_scroll_beyond_last_line_enabled(False) + self.monaco_editor.editor.set_line_numbers_mode("off") + + def _customize_overlay(self): + self._overlay_widget.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self._overlay_widget.setAutoFillBackground(True) + self._overlay_widget.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Expanding + ) + + @SafeSlot(dict) + def on_select_config(self, device: 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[0], default_flow_style=False) + self.stacked_layout.setCurrentWidget(self.monaco_editor) + except Exception: + content = traceback.format_exc() + logger.error(f"Error converting device to YAML:\n{content}") + text = "" + self.stacked_layout.setCurrentWidget(self._overlay_widget) + self.monaco_editor.set_readonly(False) # Enable editing + text = text.rstrip() + self.monaco_editor.set_text(text) + self.monaco_editor.set_readonly(True) # Disable editing again + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(widget) + widget.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + config_view = DMConfigView() + 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 new file mode 100644 index 000000000..553462a00 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_docstring_view.py @@ -0,0 +1,133 @@ +"""Module to visualize the docstring of a device class.""" + +from __future__ import annotations + +import inspect +import re +import textwrap +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 + + +def docstring_to_markdown(obj) -> str: + """ + Convert a Python docstring to Markdown suitable for QTextEdit.setMarkdown. + """ + raw = inspect.getdoc(obj) or "*No docstring available.*" + + # Dedent and normalize newlines + text = textwrap.dedent(raw).strip() + + md = "" + if hasattr(obj, "__name__"): + md += f"# {obj.__name__}\n\n" + + # Highlight section headers for Markdown + headers = ["Parameters", "Args", "Returns", "Raises", "Attributes", "Examples", "Notes"] + for h in headers: + 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: + block = re.sub(r"^ {4}", "", match.group(0), flags=re.M) + return f"```\n{block}\n```" + + doc = re.sub(r"(?m)(^ {4,}.*(\n {4,}.*)*)", fence_code, 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) + + md += doc + return md + + +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.setReadOnly(True) + + @SafeSlot(list) + def on_select_config(self, device: list[dict]): + if len(device) != 1: + self._set_text("") + return + device_class = device[0].get("deviceClass", "") + self.set_device_class(device_class) + + @SafeSlot(str) + def set_device_class(self, device_class_str: str) -> None: + if not READY_TO_VIEW: + return + try: + module_cls = get_plugin_class(device_class_str, [ophyd_devices, ophyd]) + markdown = docstring_to_markdown(module_cls) + self._set_text(markdown) + except Exception: + logger.exception("Error retrieving docstring") + 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) + 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") + 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 new file mode 100644 index 000000000..a73ada115 --- /dev/null +++ b/bec_widgets/widgets/control/device_manager/components/dm_ophyd_test.py @@ -0,0 +1,418 @@ +"""Module to run a static tests for devices from a yaml config.""" + +from __future__ import annotations + +import enum +import re +from collections import deque +from concurrent.futures import CancelledError, Future, ThreadPoolExecutor +from html import escape +from threading import Event, RLock +from typing import Any, Iterable + +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 get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + +READY_TO_TEST = False + +logger = bec_logger.logger + +try: + import bec_server + import ophyd_devices + + READY_TO_TEST = True +except ImportError: + logger.warning(f"Optional dependencies not available: {ImportError}") + ophyd_devices = None + bec_server = None + +try: + from ophyd_devices.utils.static_device_test import StaticDeviceTest +except ImportError: + StaticDeviceTest = None + + +class ValidationStatus(int, enum.Enum): + """Validation status for device configurations.""" + + PENDING = 0 # colors.default + VALID = 1 # colors.highlight + FAILED = 2 # colors.emergency + + +class DeviceValidationResult(QtCore.QObject): + """Simple object to inject validation signals into QRunnable.""" + + # Device validation signal, device_name, ValidationStatus as int, error message or '' + device_validated = QtCore.Signal(str, bool, str) + + +class DeviceTester(QtCore.QRunnable): + def __init__(self, config: dict) -> None: + super().__init__() + 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): + 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, connect = self._pending_queue.pop() + self._active.add(item) + 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, bool]]): + with self._lock: + self._pending_queue.extend(devices) + self._pending_event.set() + + @staticmethod + 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=connect) + 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: + 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): + """Custom list item widget showing device name and validation status.""" + + def __init__(self, device_name: str, device_config: dict, parent=None): + """ + Initialize the validation list item. + + Args: + device_name (str): The name of the device. + device_config (dict): The configuration of the device. + validation_colors (dict[ValidationStatus, QtGui.QColor]): The colors for each validation status. + parent (QtWidgets.QWidget, optional): The parent widget. + """ + super().__init__(parent) + self.main_layout = QtWidgets.QHBoxLayout(self) + self.main_layout.setContentsMargins(2, 2, 2, 2) + self.main_layout.setSpacing(4) + self.device_name = device_name + self.device_config = device_config + self.validation_msg = "Validation in progress..." + self._setup_ui() + + def _setup_ui(self): + """Setup the UI for the list item.""" + label = QtWidgets.QLabel(self.device_name) + self.main_layout.addWidget(label) + self.main_layout.addStretch() + self._spinner = SpinnerWidget(parent=self) + self._spinner.speed = 80 + self._spinner.setFixedSize(24, 24) + self.main_layout.addWidget(self._spinner) + self._base_style = "font-weight: bold;" + self.setStyleSheet(self._base_style) + self._start_spinner() + + def _start_spinner(self): + """Start the spinner animation.""" + self._spinner.start() + + def _stop_spinner(self): + """Stop the spinner animation.""" + self._spinner.stop() + self._spinner.setVisible(False) + + @SafeSlot() + def on_validation_restart(self): + """Handle validation restart.""" + self.validation_msg = "" + self._start_spinner() + self.setStyleSheet("") # Check if this works as expected + + @SafeSlot(str) + def on_validation_failed(self, error_msg: str): + """Handle validation failure.""" + self.validation_msg = error_msg + colors = get_accent_colors() + self._stop_spinner() + self.main_layout.removeWidget(self._spinner) + self._spinner.deleteLater() + label = QtWidgets.QLabel("") + icon = material_icon("error", color=colors.emergency, size=(24, 24)) + label.setPixmap(icon) + self.main_layout.addWidget(label) + + +class DMOphydTest(BECWidget, QtWidgets.QWidget): + """Widget to test device configurations using ophyd devices.""" + + # Signal to emit the validation status of a device + device_validated = QtCore.Signal(str, int) + # validation_msg in markdown format + validation_msg_md = QtCore.Signal(str) + + def __init__(self, parent=None, client=None): + super().__init__(parent=parent, client=client) + if not READY_TO_TEST: + self.setDisabled(True) + self.tester = None + else: + self.tester = DeviceTester({}) + self.tester.signals.device_validated.connect(self._on_device_validated) + QtCore.QThreadPool.globalInstance().start(self.tester) + self._device_list_items: dict[str, QtWidgets.QListWidgetItem] = {} + # 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(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() + + def _setup_list_ui(self): + """Setup the list UI.""" + self._list_widget = QtWidgets.QListWidget(self) + self._list_widget.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.splitter.addWidget(self._list_widget) + # Connect signals + self._list_widget.currentItemChanged.connect(self._on_current_item_changed) + + @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: + device_configs (list[dict[str, Any]]): The updated device configurations. + """ + for cfg in device_configs: + name = cfg.get("name", "") + if added: + if name in self._device_list_items: + continue + if self.tester: + self._add_device(name, cfg) + self.tester.submit([(name, cfg, connect)]) + continue + if name not in self._device_list_items: + continue + 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 + + 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) + + @SafeSlot(str, bool, str) + def _on_device_validated(self, device_name: str, success: bool, message: str): + """Handle the device validation result. + + Args: + device_name (str): The name of the device. + success (bool): Whether the validation was successful. + message (str): The validation message. + """ + logger.info(f"Device {device_name} validation result: {success}, message: {message}") + item = self._device_list_items.get(device_name, None) + if not item: + logger.error(f"Device {device_name} not found in the list.") + return + if success: + self._remove_list_item(device_name=device_name) + self.device_validated.emit(device_name, ValidationStatus.VALID.value) + else: + widget: ValidationListItem = self._list_widget.itemWidget(item) + widget.on_validation_failed(message) + self.device_validated.emit(device_name, ValidationStatus.FAILED.value) + + def _on_current_item_changed( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + """Handle the current item change in the list widget. + + Args: + current (QListWidgetItem): The currently selected item. + previous (QListWidgetItem): The previously selected item. + """ + widget: ValidationListItem = self._list_widget.itemWidget(current) + if widget: + try: + formatted_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 for device {widget.device_name}:\n{e}" + ) + self.validation_msg_md.emit(widget.validation_msg) + else: + 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. + + 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" + + # 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)) + + # 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\n---\n\n".join(blocks) + + def validation_running(self): + return self._device_list_items != {} + + @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() + self.validation_msg_md.emit("") + + 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) + + def cleanup(self): + if self.tester: + self.tester.shutdown_event.set() + return super().cleanup() + + +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) + wid = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(wid) + wid.setLayout(layout) + layout.setContentsMargins(0, 0, 0, 0) + 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/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}") + 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_()) diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 043250e3c..1a633ec3a 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 @@ -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 @@ -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() @@ -120,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) @@ -128,21 +133,20 @@ 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.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) @@ -267,9 +271,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): @@ -278,18 +280,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): @@ -298,6 +296,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) @@ -305,7 +304,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): @@ -314,12 +313,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): @@ -328,6 +328,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) @@ -347,12 +348,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): @@ -361,11 +363,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) @@ -417,9 +421,10 @@ 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) def add_arg_group(self, group: dict): """ @@ -429,9 +434,10 @@ 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) @SafeSlot(str) def emit_device_selected(self, dev_names): @@ -472,6 +478,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): @@ -524,7 +532,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): @@ -547,12 +554,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/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/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/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_()) diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py new file mode 100644 index 000000000..3186d82c4 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -0,0 +1,478 @@ +from __future__ import annotations + +import os +import pathlib +from typing import Any, cast + +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, QWidget + +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 + + +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. + """ + + 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, + variant="compact", + title="Monaco Editors", + default_add_direction="top", + **kwargs, + ) + self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event) + self.dock_manager.installEventFilter(self) + self._last_focused_editor: CDockWidget | None = None + self.focused_editor.connect(self._on_last_focused_editor_changed) + initial_editor = self.add_editor() + if isinstance(initial_editor, CDockWidget): + self.last_focused_editor = initial_editor + + 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) + return widget + + @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(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 + ) -> CDockWidget: + """ + 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. + """ + 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: + 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) + self.last_focused_editor = dock + return dock + + 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. + """ + 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() + self.last_focused_editor = dock + 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) + 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()) + 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) + 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 + editor_dock = self.add_editor(title=file, tooltip=file_name) + widget = cast(MonacoWidget, editor_dock.widget()) + widget.open_file(file_name) + 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 + ) -> 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. + 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 + 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) + return + + # Save as option + save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)") + + 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)) + + logger.debug(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. + + 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 076005309..25fd2b3d8 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -1,11 +1,24 @@ -from typing import Literal +from __future__ import annotations +import os +import traceback +from typing import TYPE_CHECKING, 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 + +if TYPE_CHECKING: + from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + +logger = bec_logger.logger class MonacoWidget(BECWidget, QWidget): @@ -14,6 +27,7 @@ class MonacoWidget(BECWidget, QWidget): """ text_changed = Signal(str) + save_enabled = Signal(bool) PLUGIN = True ICON_NAME = "code" USER_ACCESS = [ @@ -21,6 +35,7 @@ class MonacoWidget(BECWidget, QWidget): "get_text", "insert_text", "delete_line", + "open_file", "set_language", "get_language", "set_theme", @@ -32,9 +47,14 @@ 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): + 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 ) @@ -44,7 +64,30 @@ 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 = "" + 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): + """ + Get the current file being edited. + """ + return self._current_file def apply_theme(self, theme: str | None = None) -> None: """ @@ -58,14 +101,19 @@ 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, 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.editor.set_text(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: """ @@ -73,6 +121,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. @@ -93,6 +167,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, reset=True) + + @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, @@ -210,6 +310,46 @@ 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) + 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: + # 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([]) @@ -231,7 +371,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..f77e62c55 --- /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()] + 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/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index 742936dfd..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): @@ -97,7 +99,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 +143,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/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 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/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/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/image/image_roi_plot.py b/bec_widgets/widgets/plots/image/image_roi_plot.py index 7d32d6cfa..e238be641 100644 --- a/bec_widgets/widgets/plots/image/image_roi_plot.py +++ b/bec_widgets/widgets/plots/image/image_roi_plot.py @@ -1,16 +1,24 @@ import pyqtgraph as pg +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QFrame, QVBoxLayout -from bec_widgets.utils.round_frame import RoundedFrame from bec_widgets.widgets.plots.plot_base import BECViewBox -class ImageROIPlot(RoundedFrame): +class ImageROIPlot(QFrame): """ A widget for displaying an image with a region of interest (ROI) overlay. """ def __init__(self, parent=None): super().__init__(parent=parent) + self.setAttribute(Qt.WA_StyledBackground, True) + self.setProperty("variant", "plot_background") + self.setProperty("frameless", True) + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(5, 5, 5, 5) + self.layout.setSpacing(0) self.content_widget = pg.GraphicsLayoutWidget(self) self.layout.addWidget(self.content_widget) @@ -27,7 +35,15 @@ def apply_theme(self, theme: str): self.curve_color = "k" for curve in self.plot_item.curves: curve.setPen(pg.mkPen(self.curve_color, width=3)) - super().apply_theme(theme) + + self.apply_plot_widget_style() + + def apply_plot_widget_style(self, border: str = "none"): + """Keep pyqtgraph widgets styled by QSS/themes.""" + if border != "none": + self.content_widget.setStyleSheet(f"border: {border};") + else: + self.content_widget.setStyleSheet("") def cleanup_pyqtgraph(self): """Cleanup pyqtgraph items.""" diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index cde4c5d0a..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 @@ -128,6 +128,8 @@ class MotorMap(PlotBase): "y_log.setter", "legend_label_size", "legend_label_size.setter", + "attach", + "detach", "screenshot", # motor_map specific "color", @@ -828,7 +830,7 @@ 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/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/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index 1e112ed7c..1b20b284d 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -6,14 +6,13 @@ import pyqtgraph as pg from bec_lib import bec_logger from qtpy.QtCore import QPoint, QPointF, Qt, Signal -from qtpy.QtWidgets import QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget +from qtpy.QtWidgets import QFrame, QHBoxLayout, QLabel, QMainWindow, QVBoxLayout, QWidget from bec_widgets.utils import ConnectionConfig, Crosshair, EntryValidator from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.fps_counter import FPSCounter from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem -from bec_widgets.utils.round_frame import RoundedFrame from bec_widgets.utils.side_panel import SidePanel from bec_widgets.utils.toolbars.performance import PerformanceConnection, performance_bundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar @@ -129,20 +128,35 @@ 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() 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) + self.apply_plot_widget_style() + super().apply_theme(theme) 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 = QFrame(parent=self) + self.round_plot_widget.setAttribute(Qt.WA_StyledBackground, True) + self.round_plot_widget.setProperty("variant", "plot_background") + self.round_plot_widget.setProperty("frameless", True) + + plot_frame_layout = QVBoxLayout(self.round_plot_widget) + plot_frame_layout.setContentsMargins(5, 5, 5, 5) + plot_frame_layout.setSpacing(0) + plot_frame_layout.addWidget(self.plot_widget) self.layout_manager.add_widget(self.round_plot_widget) self.layout_manager.add_widget_relative(self.fps_label, self.round_plot_widget, "top") @@ -150,11 +164,22 @@ def _init_ui(self): self.layout_manager.add_widget_relative(self.side_panel, self.round_plot_widget, "left") self.layout_manager.add_widget_relative(self.toolbar, self.fps_label, "top") + self.apply_plot_widget_style() + self.ui_mode = self._ui_mode # to initiate the first time # PlotItem ViewBox Signals self.plot_item.vb.sigStateChanged.connect(self.viewbox_state_changed) + def apply_plot_widget_style(self, border: str = "none"): + """Let theme/QSS style the plot widget; keep custom overrides minimal.""" + if not isinstance(self.plot_widget, pg.GraphicsLayoutWidget): + return + if border != "none": + self.plot_widget.setStyleSheet(f"border: {border};") + else: + self.plot_widget.setStyleSheet("") + def _init_toolbar(self): self.toolbar.add_bundle(performance_bundle(self.toolbar.components)) self.toolbar.add_bundle(plot_export_bundle(self.toolbar.components)) @@ -292,7 +317,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): @@ -302,6 +327,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.") @@ -309,7 +335,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): @@ -319,9 +345,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() ################################################################################ @@ -794,7 +822,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): @@ -807,6 +835,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.") @@ -814,7 +843,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): @@ -827,6 +856,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) @@ -967,6 +997,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.""" @@ -978,6 +1009,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/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 9f4adc6f3..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 @@ -84,6 +83,8 @@ class ScatterWaveform(PlotBase): "legend_label_size.setter", "minimal_crosshair_precision", "minimal_crosshair_precision.setter", + "attach", + "detach", "screenshot", # Scatter Waveform Specific RPC Access "main_curve", @@ -544,8 +545,10 @@ 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/settings/curve_settings/curve_tree.py b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py index a5951cf31..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 @@ -34,6 +34,7 @@ def validate(self, input_str: str, pos: int): from qtpy.QtWidgets import ( + QApplication, QComboBox, QHBoxLayout, QHeaderView, @@ -97,6 +98,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 +196,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) diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index fb71c8b1a..e847ab2f5 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 @@ -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", @@ -572,7 +575,7 @@ def x_mode(self, value: str): self.async_signal_update.emit() self.sync_signal_update.emit() self.plot_item.enableAutoRange(x=True) - self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme + self.apply_plot_widget_style() # To keep the correct theme @SafeProperty(str) def x_entry(self) -> str | None: @@ -601,7 +604,7 @@ def x_entry(self, value: str | None): self.async_signal_update.emit() self.sync_signal_update.emit() self.plot_item.enableAutoRange(x=True) - self.round_plot_widget.apply_plot_widget_style() + self.apply_plot_widget_style() @SafeProperty(str) def color_palette(self) -> str: @@ -2390,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/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/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 1530afdaf..d7d095152 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): @@ -197,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: @@ -242,8 +244,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): 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..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 @@ -76,7 +76,7 @@ class BECStatusBox(BECWidget, CompactPopupWidget): PLUGIN = True CORE_SERVICES = ["DeviceServer", "ScanServer", "SciHub", "ScanBundler", "FileWriterManager"] - USER_ACCESS = ["get_server_state", "remove"] + USER_ACCESS = ["get_server_state", "remove", "attach", "detach", "screenshot"] service_update = Signal(BECServiceInfoContainer) bec_core_state = Signal(str) @@ -315,10 +315,10 @@ 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..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 @@ -11,23 +9,17 @@ 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 ( - DeviceConfigDialog, + DirectUpdateDeviceConfigDialog, ) from bec_widgets.widgets.services.device_browser.util import map_device_type_to_icon @@ -59,7 +51,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 @@ -114,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: @@ -132,25 +125,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 +141,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 +174,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): @@ -240,10 +207,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_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/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 ) 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..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 @@ -5,12 +5,14 @@ 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 BaseModel, field_validator +from qtpy.QtCore import QSize, Qt, QThreadPool, Signal # type: ignore from qtpy.QtWidgets import ( QApplication, + QComboBox, QDialog, QDialogButtonBox, + QHBoxLayout, QLabel, QStackedLayout, QVBoxLayout, @@ -19,6 +21,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, ) @@ -29,6 +32,8 @@ logger = bec_logger.logger +_StdBtn = QDialogButtonBox.StandardButton + def _try_literal_eval(value: str): if value == "": @@ -39,79 +44,36 @@ 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() + accepted_data = Signal(dict) def __init__( - self, - *, - parent=None, - device: str | None = None, - config_helper: ConfigHelper | None = None, - action: Literal["update", "add"] = "update", - threadpool: QThreadPool | None = None, - **kwargs, + self, *, parent=None, class_deviceconfig_item: type[DynamicFormItem] | 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._initial_config = {} + self._class_deviceconfig_item = class_deviceconfig_item 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._data = {} 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) + self._form._validity.setVisible(True) + self._connect_form() - 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 _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() @@ -119,16 +81,6 @@ def _add_form(self): 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 +97,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 +133,16 @@ 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): + self.accepted_data.emit(self._form.get_form_data()) + + def get_data(self): + return self._data + @SafeSlot(popup_error=True) def apply(self): self._process_action() @@ -206,10 +153,138 @@ def accept(self): self._process_action() return super().accept() + +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._device_models = { + "EpicsMotor": (EpicsMotorConfig, {"deviceClass": ("ophyd.EpicsMotor", False)}), + "EpicsSignalRO": (EpicsSignalROConfig, {"deviceClass": ("ophyd.EpicsSignalRO", False)}), + "EpicsSignal": (EpicsSignalConfig, {"deviceClass": ("ophyd.EpicsSignal", False)}), + "Custom": (None, {}), + } + 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(): + 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(list(self._device_models.keys())) + layout.addWidget(QLabel("Choose a device class: ")) + layout.addWidget(self._selection_box) + self._layout.insertLayout(0, layout) + + +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, + ): + """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( + 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() + 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) + + 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}!" ) @@ -249,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 @@ -262,17 +337,17 @@ 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()) + widget.setLayout(layout := QVBoxLayout()) device = QLineEdit() - widget.layout().addWidget(device) + layout.addWidget(device) def _destroy_dialog(*_): nonlocal dialog @@ -285,14 +360,14 @@ def accept(*args): def _show_dialog(*_): nonlocal dialog if dialog is None: - kwargs = {"device": dev} if (dev := device.text()) else {"action": "add"} - dialog = DeviceConfigDialog(**kwargs) + 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() 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_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() 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..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, @@ -35,9 +35,6 @@ class DeviceItem(ExpandableGroupFrame): - broadcast_size_hint = Signal(QSize) - imminent_deletion = Signal() - RPC = False def __init__( @@ -94,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/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/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index 38a5b2744..2c19a1760 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -1,13 +1,19 @@ import datetime import importlib +import importlib.metadata import os +import re +from typing import Literal +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 +23,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 +44,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 +61,29 @@ 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 _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) - 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) @@ -67,24 +96,98 @@ 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 - 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) - # macros_section = CollapsibleSection("MACROS", indentation=0) - # macros_section.set_widget(QLabel("Macros will be implemented later")) - # self.main_explorer.add_section(macros_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) + + def add_macro_section(self): + section = CollapsibleSection( + 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), convert_to_pixmap=False) + ) + 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 = self._get_plugin_dir("macros") + + 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 _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") + + 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 +239,134 @@ 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("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)}") + + 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 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 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/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) 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) 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..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 @@ -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): @@ -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) @@ -85,7 +86,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 +101,7 @@ def update_mode_button(self): if __name__ == "__main__": app = QApplication([]) - set_theme("auto") + apply_theme("dark") w = DarkModeButton() w.show() diff --git a/pyproject.toml b/pyproject.toml index 161c5d194..ea85f719a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,16 +15,22 @@ classifiers = [ dependencies = [ "bec_ipython_client~=3.70", # needed for jupyter console "bec_lib~=3.70", - "bec_qthemes~=0.7, >=0.7", - "black~=25.0", # needed for bw-generate-cli - "isort~=5.13, >=5.13.2", # needed for bw-generate-cli + "bec_qthemes~=1.0, >=1.1.2", + "black~=25.0", # needed for bw-generate-cli + "isort~=5.13, >=5.13.2", # needed for bw-generate-cli "pydantic~=2.0", - "pyqtgraph~=0.13", + "pyqtgraph==0.13.7", "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", + "qtmonaco~=0.8, >=0.8.1", + "darkdetect~=0.8", + "PySide6-QtAds==4.4.0", + "pylsp-bec~=1.2", + "copier~=9.7", + "typer~=0.15", + "markdown~=3.9", ] @@ -42,7 +48,6 @@ dev = [ "pytest-cov~=6.1.1", "watchdog~=6.0", "pre_commit~=4.2", - ] [project.urls] 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""" diff --git a/tests/references/SpinnerWidget/SpinnerWidget_darwin.png b/tests/references/SpinnerWidget/SpinnerWidget_darwin.png index 2b75d66a8..54bd8c5e3 100644 Binary files a/tests/references/SpinnerWidget/SpinnerWidget_darwin.png and b/tests/references/SpinnerWidget/SpinnerWidget_darwin.png differ diff --git a/tests/references/SpinnerWidget/SpinnerWidget_linux.png b/tests/references/SpinnerWidget/SpinnerWidget_linux.png index 2b75d66a8..462241215 100644 Binary files a/tests/references/SpinnerWidget/SpinnerWidget_linux.png and b/tests/references/SpinnerWidget/SpinnerWidget_linux.png differ diff --git a/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png b/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png index ff6827cd8..85c5a2441 100644 Binary files a/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png and b/tests/references/SpinnerWidget/SpinnerWidget_started_darwin.png differ diff --git a/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png b/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png index bf2d9470f..662bd4f75 100644 Binary files a/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png and b/tests/references/SpinnerWidget/SpinnerWidget_started_linux.png differ 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 diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index db5427dc3..47a7a1c76 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -6,7 +6,9 @@ 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.QtCore import QEvent, QEventLoop from qtpy.QtWidgets import QApplication, QMessageBox from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -23,12 +25,26 @@ def pytest_runtest_makereport(item, call): item.stash["failed"] = rep.failed +def process_all_deferred_deletes(qapp): + qapp.sendPostedEvents(None, QEvent.DeferredDelete) + qapp.processEvents(QEventLoop.AllEvents) + + @pytest.fixture(autouse=True) def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unused-argument + qapp = QApplication.instance() + process_all_deferred_deletes(qapp) + apply_theme("light") + qapp.processEvents() + yield # if the test failed, we don't want to check for open widgets as # 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 @@ -36,7 +52,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) diff --git a/tests/unit_tests/test_abort_button.py b/tests/unit_tests/test_abort_button.py index ca6c18a29..d744bd048 100644 --- a/tests/unit_tests/test_abort_button.py +++ b/tests/unit_tests/test_abort_button.py @@ -17,10 +17,6 @@ def abort_button(qtbot, mocked_client): def test_abort_button(abort_button): assert abort_button.button.text() == "Abort" - assert ( - abort_button.button.styleSheet() - == "background-color: #666666; color: white; font-weight: bold; font-size: 12px;" - ) abort_button.button.click() assert abort_button.queue.request_scan_abortion.called abort_button.close() 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..f108d39b5 --- /dev/null +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -0,0 +1,2263 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import + +import base64 +import os +from unittest import mock +from unittest.mock import MagicMock, patch + +import pytest +from qtpy.QtCore import QSettings, Qt, QTimer +from qtpy.QtGui import QPixmap +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, + 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 ( + SETTINGS_KEYS, + default_profile_path, + get_profile_info, + is_profile_read_only, + is_quick_select, + list_profiles, + load_default_profile_screenshot, + load_user_profile_screenshot, + open_default_settings, + open_user_settings, + plugin_profiles_dir, + read_manifest, + 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 + + +@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(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(): + """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 + + +@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_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} + + 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 == "creator" + 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_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("DarkModeButton") + + # 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, qtbot): + """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 + + 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.""" + # 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_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 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.""" + + 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_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", "terminal", "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() + 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.""" + # 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_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 + 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" + + 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.quick_select_checkbox.setChecked(True) + + assert dialog.get_profile_name() == "my_profile" + 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.""" + 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() + + 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() + monkeypatch.setattr( + profile_utils, + "get_profile_info", + lambda *a, **k: profile_utils.ProfileInfo(name=readonly, is_read_only=True), + ) + 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" + 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, namespace=None: pix, + ) + monkeypatch.setattr( + "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot", + lambda name, namespace=None: 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() + + 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" + 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( + "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" + 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() + + 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" + 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() + + 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" + helper = profile_helper(advanced_dock_area) + helper.open_user("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 = 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_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 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_user_settings(name) + settings.setValue("test", "value") + settings.sync() + + profiles = list_profiles() + for name in profile_names: + assert name in profiles + + def test_readonly_profile_operations(self, temp_profile_dir, module_profile_factory): + """Test read-only profile functionality.""" + profile_name = "user_profile" + + # Initially should not be read-only + assert not is_profile_read_only(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) + + # 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_user_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 + + 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, module_profile_factory + ): + """Test saving profile when read-only profile exists.""" + profile_name = module_profile_factory("readonly_profile") + new_profile = f"{profile_name}_custom" + helper = profile_helper(advanced_dock_area) + target_path = helper.user_path(new_profile) + if os.path.exists(target_path): + os.remove(target_path) + + 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(profile_name) + + 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" + helper = profile_helper(advanced_dock_area) + + # Create a profile with manifest + settings = helper.open_user(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() + + # 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_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" + helper = profile_helper(advanced_dock_area) + + settings = helper.open_user(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(helper.open_user(source_profile)) + new_manifest = read_manifest(helper.open_user(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" + helper = profile_helper(advanced_dock_area) + + for profile in (profile_a, profile_b): + settings = helper.open_user(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(helper.open_user(profile_a)) + assert len(manifest_a) == 2 + + 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") + 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 = helper.user_path(profile_name) + default_path = helper.default_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() + 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", + 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_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 = helper.open_user(profile_name) + settings.setValue("test", "value") + settings.sync() + 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: + 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(user_path) + + 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 = helper.open_user(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_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") + + 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 + assert dock.setting_action.toolTip() == "Dock settings" + + dock.setting_action.trigger() + mock_open_dialog.assert_called_once_with(dock, widget) + + +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", "creator", "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"] + + +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 = "creator" + + 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_status", + "flat_progress_bar", + "flat_terminal", + "flat_bec_shell", + "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_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_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_status": "BECStatusBox", + "flat_progress_bar": "RingProgressBar", + "flat_terminal": "WebConsole", + "flat_bec_shell": "WebConsole", + "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_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", "creator", "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 = "creator" + 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", "creator", "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] 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_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()) 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_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) 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 diff --git a/tests/unit_tests/test_dark_mode_button.py b/tests/unit_tests/test_dark_mode_button.py index 3dca50a20..1491ebc65 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 @@ -64,23 +64,10 @@ def test_dark_mode_button_changes_theme(dark_mode_button): Test that the dark mode button changes the theme correctly. """ with mock.patch( - "bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.set_theme" - ) as mocked_set_theme: + "bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button.apply_theme" + ) as mocked_apply_theme: dark_mode_button.toggle_dark_mode() - mocked_set_theme.assert_called_with("dark") + mocked_apply_theme.assert_called_with("dark") dark_mode_button.toggle_dark_mode() - mocked_set_theme.assert_called_with("light") - - -def test_dark_mode_button_changes_on_os_theme_change(qtbot, dark_mode_button): - """ - Test that the dark mode button changes the theme correctly when the OS theme changes. - """ - qapp = QApplication.instance() - assert dark_mode_button.dark_mode_enabled is False - assert dark_mode_button.mode_button.toolTip() == "Set Dark Mode" - qapp.theme_signal.theme_updated.emit("dark") - qtbot.wait(100) - assert dark_mode_button.dark_mode_enabled is True - assert dark_mode_button.mode_button.toolTip() == "Set Light Mode" + mocked_apply_theme.assert_called_with("light") diff --git a/tests/unit_tests/test_developer_view.py b/tests/unit_tests/test_developer_view.py new file mode 100644 index 000000000..56971d3b7 --- /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.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 .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_device_browser.py b/tests/unit_tests/test_device_browser.py index 3ef97af8a..7c36594ee 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) @@ -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 @@ -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): 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) 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"] 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"] 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..a85be7319 --- /dev/null +++ b/tests/unit_tests/test_device_manager_view.py @@ -0,0 +1,224 @@ +"""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 ( + ConfigChoiceDialog, + DeviceManagerView, +) +from bec_widgets.utils.help_inspector.help_inspector import HelpInspector +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 + + +@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_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_guided_tour.py b/tests/unit_tests/test_guided_tour.py new file mode 100644 index 000000000..41d3320ad --- /dev/null +++ b/tests/unit_tests/test_guided_tour.py @@ -0,0 +1,405 @@ +from unittest import mock + +import pytest +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 +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, enforce_visibility=False) + + +@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" + + 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.""" + # 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 diff --git a/tests/unit_tests/test_help_inspector.py b/tests/unit_tests/test_help_inspector.py new file mode 100644 index 000000000..5ab96274f --- /dev/null +++ b/tests/unit_tests/test_help_inspector.py @@ -0,0 +1,132 @@ +# 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 + + +@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 + + +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() 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_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 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..f0b39506b 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, metadata={'sample_name': ''})" + assert out == expected_code 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 diff --git a/tests/unit_tests/test_round_frame.py b/tests/unit_tests/test_round_frame.py deleted file mode 100644 index ba46219e6..000000000 --- a/tests/unit_tests/test_round_frame.py +++ /dev/null @@ -1,63 +0,0 @@ -import pyqtgraph as pg -import pytest - -from bec_widgets.utils.round_frame import RoundedFrame - - -def cleanup_pyqtgraph(plot_widget): - item = plot_widget.getPlotItem() - item.vb.menu.close() - item.vb.menu.deleteLater() - item.ctrlMenu.close() - item.ctrlMenu.deleteLater() - - -@pytest.fixture -def basic_rounded_frame(qtbot): - frame = RoundedFrame() - qtbot.addWidget(frame) - qtbot.waitExposed(frame) - yield frame - - -@pytest.fixture -def plot_rounded_frame(qtbot): - plot_widget = pg.PlotWidget() - plot_widget.plot([0, 1, 2], [2, 1, 0]) - frame = RoundedFrame(content_widget=plot_widget) - qtbot.addWidget(frame) - qtbot.waitExposed(frame) - yield frame - cleanup_pyqtgraph(plot_widget) - - -def test_basic_rounded_frame_initialization(basic_rounded_frame): - assert basic_rounded_frame.radius == 10 - assert basic_rounded_frame.content_widget is None - assert basic_rounded_frame.background_color is None - - -def test_set_radius(basic_rounded_frame): - basic_rounded_frame.radius = 20 - 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") - - # Ensure style application did not break anything - assert plot_rounded_frame.content_widget is not None - assert isinstance(plot_rounded_frame.content_widget, pg.PlotWidget) 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) 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()