diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py index ca15a3ce..8dd196e7 100644 --- a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -3,9 +3,18 @@ from __future__ import annotations from bec_qthemes import material_icon from qtpy.QtCore import QMimeData, Qt, Signal from qtpy.QtGui import QDrag -from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget +from qtpy.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.widgets.containers.main_window.addons.hover_widget import WidgetTooltip class CollapsibleSection(QWidget): @@ -34,6 +43,9 @@ class CollapsibleSection(QWidget): super().__init__(parent=parent) self.title = title self.content_widget = None + self._help_text = tooltip + self._help_tooltip: WidgetTooltip | None = None + self.destroyed.connect(self._cleanup_help_tooltip) self.setAcceptDrops(True) self._expanded = True @@ -58,13 +70,24 @@ class CollapsibleSection(QWidget): 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 # Add header to layout header_layout.addWidget(self.header_button) + self.header_info_button = QToolButton() + self.header_info_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.header_info_button.setFixedSize(20, 20) + self.header_info_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + self.header_info_button.setAutoRaise(True) + self.header_info_button.setEnabled(bool(self._help_text)) + self.header_info_button.setVisible(bool(self._help_text)) + self.header_info_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.header_info_button.setIcon(material_icon("info", convert_to_pixmap=False)) + if self._help_text: + self.header_info_button.setToolTip("Show help") + self.header_info_button.clicked.connect(self._toggle_help_tooltip) + header_layout.addWidget(self.header_info_button) header_layout.addStretch() # Add button in header (icon-only) @@ -146,6 +169,37 @@ class CollapsibleSection(QWidget): """ self.header_add_button.clicked.connect(slot) + def closeEvent(self, event): + self._cleanup_help_tooltip() + super().closeEvent(event) + + def _toggle_help_tooltip(self): + """Show or hide the section help popover near the info button.""" + if not self._help_text: + return + + if self._help_tooltip is not None and self._help_tooltip.isVisible(): + self._help_tooltip.hide() + return + + if self._help_tooltip is None: + help_label = QLabel(self._help_text) + help_label.setWordWrap(True) + help_label.setMinimumWidth(220) + help_label.setMaximumWidth(320) + self._help_tooltip = WidgetTooltip(help_label) + + anchor = self.header_info_button.mapToGlobal(self.header_info_button.rect().center()) + self._help_tooltip.show_near(anchor) + + def _cleanup_help_tooltip(self, *_args): + """Close and release any help popup owned by this section.""" + if self._help_tooltip is None: + return + self._help_tooltip.close() + self._help_tooltip.deleteLater() + self._help_tooltip = None + def _header_mouse_press_event(self, event): """Handle mouse press on header for drag start""" if event.button() == Qt.MouseButton.LeftButton: diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index 7a48b0f1..79f71c68 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -78,7 +78,15 @@ class IDEExplorer(BECWidget, QWidget): self._remove_section(section) def add_script_section(self): - section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0) + section = CollapsibleSection( + parent=self, + title="SCRIPTS", + indentation=0, + tooltip="Scripts are executable Python files for scan workflows, " + "automation, and quick experiments.\n" + "In contrast to macros, scripts can contain executable code outside of function definitions.\n" + "Scripts can be run directly from the IDE by clicking the 'Run' button in the toolbar.", + ) script_explorer = Explorer(parent=self) local_script_dir = os.path.expanduser( @@ -120,7 +128,10 @@ class IDEExplorer(BECWidget, QWidget): title="MACROS", indentation=0, show_add_button=True, - tooltip="Macros are reusable functions that can be called from scripts or the console.", + tooltip="Macros are reusable Python functions that can be called from scripts or the console.\n" + "All macros are automatically loaded and available for use. As a result,\n" + "macros must not have executable code outside of function definitions to\n" + "avoid unintended execution when imported.", ) section.header_add_button.setIcon( material_icon("refresh", size=(20, 20), convert_to_pixmap=False) diff --git a/tests/unit_tests/test_ide_explorer.py b/tests/unit_tests/test_ide_explorer.py index 6a1f6b6a..aee2536b 100644 --- a/tests/unit_tests/test_ide_explorer.py +++ b/tests/unit_tests/test_ide_explorer.py @@ -24,6 +24,41 @@ def test_ide_explorer_initialization(ide_explorer): assert ide_explorer.main_explorer.sections[0].title == "SCRIPTS" +def test_script_section_has_info_tooltip(ide_explorer): + """Scripts section exposes click-to-open help on the info icon.""" + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + + assert not scripts_section.header_button.toolTip() + assert not scripts_section.header_info_button.isHidden() + assert scripts_section.header_info_button.isEnabled() + assert scripts_section.header_info_button.toolTip() == "Show help" + + +def test_macro_section_has_info_tooltip(ide_explorer): + """Macros section exposes click-to-open help on the info icon.""" + macros_section = ide_explorer.main_explorer.get_section("MACROS") + + assert not macros_section.header_button.toolTip() + assert not macros_section.header_info_button.isHidden() + assert macros_section.header_info_button.isEnabled() + assert macros_section.header_info_button.toolTip() == "Show help" + + +def test_script_section_info_button_opens_help_popup(ide_explorer, qtbot): + """Clicking the info button should show the styled help popup.""" + scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS") + + scripts_section.header_info_button.click() + qtbot.waitUntil( + lambda: scripts_section._help_tooltip is not None + and scripts_section._help_tooltip.isVisible(), + timeout=1000, + ) + + assert scripts_section._help_tooltip.content.text().startswith("Scripts are executable") + scripts_section._cleanup_help_tooltip() + + def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir): local_script_section = ide_explorer.main_explorer.get_section( "SCRIPTS"