From 1bec9bd9b2238ed484e8d25e691326efe5730f6b Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sat, 26 Jul 2025 19:45:24 +0200 Subject: [PATCH] feat: add explorer widget --- .../widgets/containers/explorer/__init__.py | 0 .../explorer/collapsible_tree_section.py | 204 +++++++++ .../widgets/containers/explorer/explorer.py | 179 ++++++++ .../containers/explorer/script_tree_widget.py | 387 ++++++++++++++++++ .../utility/ide_explorer/ide_explorer.py | 146 +++++++ .../ide_explorer/ide_explorer.pyproject | 1 + .../ide_explorer/ide_explorer_plugin.py | 54 +++ .../ide_explorer/register_ide_explorer.py | 15 + tests/unit_tests/test_explorer.py | 55 +++ tests/unit_tests/test_ide_explorer.py | 36 ++ tests/unit_tests/test_script_tree_widget.py | 118 ++++++ 11 files changed, 1195 insertions(+) create mode 100644 bec_widgets/widgets/containers/explorer/__init__.py create mode 100644 bec_widgets/widgets/containers/explorer/collapsible_tree_section.py create mode 100644 bec_widgets/widgets/containers/explorer/explorer.py create mode 100644 bec_widgets/widgets/containers/explorer/script_tree_widget.py create mode 100644 bec_widgets/widgets/utility/ide_explorer/ide_explorer.py create mode 100644 bec_widgets/widgets/utility/ide_explorer/ide_explorer.pyproject create mode 100644 bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py create mode 100644 bec_widgets/widgets/utility/ide_explorer/register_ide_explorer.py create mode 100644 tests/unit_tests/test_explorer.py create mode 100644 tests/unit_tests/test_ide_explorer.py create mode 100644 tests/unit_tests/test_script_tree_widget.py diff --git a/bec_widgets/widgets/containers/explorer/__init__.py b/bec_widgets/widgets/containers/explorer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py new file mode 100644 index 00000000..ad0b9ae1 --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/collapsible_tree_section.py @@ -0,0 +1,204 @@ +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, QVBoxLayout, QWidget + +from bec_widgets.utils.colors import get_theme_palette +from bec_widgets.utils.error_popups import SafeProperty + + +class CollapsibleSection(QWidget): + """A widget that combines a header button with any content widget for collapsible sections + + This widget contains a header button with a title and a content widget. + The content widget can be any QWidget. The header button can be expanded or collapsed. + The header also contains an "Add" button that is only visible when hovering over the section. + + Signals: + section_reorder_requested(str, str): Emitted when the section is dragged and dropped + onto another section for reordering. + Arguments are (source_title, target_title). + """ + + section_reorder_requested = Signal(str, str) # (source_title, target_title) + + def __init__(self, parent=None, title="", indentation=10, show_add_button=False): + super().__init__(parent=parent) + self.title = title + self.content_widget = None + self.setAcceptDrops(True) + self._expanded = True + + # Setup layout + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(indentation, 0, 0, 0) + self.main_layout.setSpacing(0) + + header_layout = QHBoxLayout() + header_layout.setContentsMargins(0, 0, 4, 0) + header_layout.setSpacing(0) + + # Create header button + self.header_button = QPushButton() + self.header_button.clicked.connect(self.toggle_expanded) + + # Enable drag and drop for reordering + self.header_button.setAcceptDrops(True) + self.header_button.mousePressEvent = self._header_mouse_press_event + 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 + + self.drag_start_position = None + + # Add header to layout + header_layout.addWidget(self.header_button) + header_layout.addStretch() + + self.header_add_button = QPushButton() + self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.header_add_button.setFixedSize(20, 20) + self.header_add_button.setToolTip("Add item") + self.header_add_button.setVisible(show_add_button) + + self.header_add_button.setIcon(material_icon("add", size=(20, 20))) + header_layout.addWidget(self.header_add_button) + + self.main_layout.addLayout(header_layout) + + self._update_expanded_state() + + def set_widget(self, widget): + """Set the content widget for this collapsible section""" + # Remove existing content widget if any + if self.content_widget and self.content_widget.parent() == self: + self.main_layout.removeWidget(self.content_widget) + self.content_widget.close() + self.content_widget.deleteLater() + + self.content_widget = widget + if self.content_widget: + self.main_layout.addWidget(self.content_widget) + + self._update_expanded_state() + + def _update_appearance(self): + """Update the header button appearance based on expanded state""" + # Use material icons with consistent sizing to match tree items + icon_name = "keyboard_arrow_down" if self.expanded else "keyboard_arrow_right" + icon = material_icon(icon_name=icon_name, size=(20, 20), convert_to_pixmap=False) + + self.header_button.setIcon(icon) + self.header_button.setText(self.title) + + # Get theme colors + palette = get_theme_palette() + text_color = palette.text().color().name() + + self.header_button.setStyleSheet( + f""" + QPushButton {{ + font-weight: bold; + text-align: left; + margin: 0; + padding: 0px; + border: none; + background: transparent; + color: {text_color}; + icon-size: 20px 20px; + }} + """ + ) + + def toggle_expanded(self): + """Toggle the expanded state and update size policy""" + self.expanded = not self.expanded + self._update_expanded_state() + + def _update_expanded_state(self): + """Update the expanded state based on current state""" + self._update_appearance() + if self.expanded: + if self.content_widget: + self.content_widget.show() + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + else: + if self.content_widget: + self.content_widget.hide() + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + @SafeProperty(bool) + def expanded(self) -> bool: + """Get the expanded state""" + return self._expanded + + @expanded.setter + def expanded(self, value: bool): + """Set the expanded state programmatically""" + if not isinstance(value, bool): + raise ValueError("Expanded state must be a boolean") + if self._expanded == value: + return + self._expanded = value + self._update_appearance() + + def connect_add_button(self, slot): + """Connect a slot to the add button's clicked signal. + + Args: + slot: The function to call when the add button is clicked. + """ + self.header_add_button.clicked.connect(slot) + + def _header_mouse_press_event(self, event): + """Handle mouse press on header for drag start""" + if event.button() == Qt.MouseButton.LeftButton: + self.drag_start_position = event.pos() + QPushButton.mousePressEvent(self.header_button, event) + + def _header_mouse_move_event(self, event): + """Handle mouse move to start drag operation""" + if event.buttons() & Qt.MouseButton.LeftButton and self.drag_start_position is not None: + + # Check if we've moved far enough to start a drag + if (event.pos() - self.drag_start_position).manhattanLength() >= 10: + + self._start_drag() + QPushButton.mouseMoveEvent(self.header_button, event) + + def _start_drag(self): + """Start the drag operation with a properly aligned widget pixmap""" + drag = QDrag(self.header_button) + mime_data = QMimeData() + mime_data.setText(f"section:{self.title}") + drag.setMimeData(mime_data) + + # Grab a pixmap of the widget + widget_pixmap = self.header_button.grab() + + drag.setPixmap(widget_pixmap) + + # Set the hotspot to where the mouse was pressed on the widget + drag.setHotSpot(self.drag_start_position) + + drag.exec_(Qt.MoveAction) + + def _header_drag_enter_event(self, event): + """Handle drag enter on header""" + if event.mimeData().hasText() and event.mimeData().text().startswith("section:"): + event.acceptProposedAction() + else: + event.ignore() + + def _header_drop_event(self, event): + """Handle drop on header""" + if event.mimeData().hasText() and event.mimeData().text().startswith("section:"): + source_title = event.mimeData().text().replace("section:", "") + if source_title != self.title: + # Emit signal to parent to handle reordering + self.section_reorder_requested.emit(source_title, self.title) + event.acceptProposedAction() + else: + event.ignore() diff --git a/bec_widgets/widgets/containers/explorer/explorer.py b/bec_widgets/widgets/containers/explorer/explorer.py new file mode 100644 index 00000000..b780cbde --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/explorer.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QSizePolicy, QSpacerItem, QSplitter, QVBoxLayout, QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_theme_palette +from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection + + +class Explorer(BECWidget, QWidget): + """ + A widget that combines multiple collapsible sections for an explorer-like interface. + Each section can be expanded or collapsed, and sections can be reordered. The explorer + can contain also sub-explorers for nested structures. + """ + + RPC = False + PLUGIN = False + + def __init__(self, parent=None): + super().__init__(parent) + + # Main layout + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.main_layout.setSpacing(0) + + # Splitter for sections + self.splitter = QSplitter(Qt.Orientation.Vertical) + self.main_layout.addWidget(self.splitter) + + # Spacer for when all sections are collapsed + self.expander = QSpacerItem(0, 0) + self.main_layout.addItem(self.expander) + + # Registry of sections + self.sections: list[CollapsibleSection] = [] + + # Setup splitter styling + self._setup_splitter_styling() + + def add_section(self, section: CollapsibleSection) -> None: + """ + Add a collapsible section to the explorer + + Args: + section (CollapsibleSection): The section to add + """ + if not isinstance(section, CollapsibleSection): + raise TypeError("section must be an instance of CollapsibleSection") + + if section in self.sections: + return + + self.sections.append(section) + self.splitter.addWidget(section) + + # Connect the section's toggle to update spacer + section.header_button.clicked.connect(self._update_spacer) + + # Connect section reordering if supported + if hasattr(section, "section_reorder_requested"): + section.section_reorder_requested.connect(self._handle_section_reorder) + + self._update_spacer() + + def remove_section(self, section: CollapsibleSection) -> None: + """ + Remove a collapsible section from the explorer + + Args: + section (CollapsibleSection): The section to remove + """ + if section not in self.sections: + return + self.sections.remove(section) + section.deleteLater() + section.close() + + # Disconnect signals + try: + section.header_button.clicked.disconnect(self._update_spacer) + if hasattr(section, "section_reorder_requested"): + section.section_reorder_requested.disconnect(self._handle_section_reorder) + except RuntimeError: + # Signals already disconnected + pass + + self._update_spacer() + + def get_section(self, title: str) -> CollapsibleSection | None: + """Get a section by its title""" + for section in self.sections: + if section.title == title: + return section + return None + + def _setup_splitter_styling(self) -> None: + """Setup the splitter styling with theme colors""" + palette = get_theme_palette() + separator_color = palette.mid().color() + + self.splitter.setStyleSheet( + f""" + QSplitter::handle {{ + height: 0.1px; + background-color: rgba({separator_color.red()}, {separator_color.green()}, {separator_color.blue()}, 60); + }} + """ + ) + + def _update_spacer(self) -> None: + """Update the spacer size based on section states""" + any_expanded = any(section.expanded for section in self.sections) + + if any_expanded: + self.expander.changeSize(0, 0, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + else: + self.expander.changeSize( + 0, 10, QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding + ) + + def _handle_section_reorder(self, source_title: str, target_title: str) -> None: + """Handle reordering of sections""" + if source_title == target_title: + return + + source_section = self.get_section(source_title) + target_section = self.get_section(target_title) + + if not source_section or not target_section: + return + + # Get current indices + source_index = self.splitter.indexOf(source_section) + target_index = self.splitter.indexOf(target_section) + + if source_index == -1 or target_index == -1: + return + + # Insert at target position + self.splitter.insertWidget(target_index, source_section) + + # Update sections + self.sections.remove(source_section) + self.sections.insert(target_index, source_section) + + +if __name__ == "__main__": + import os + + from qtpy.QtWidgets import QApplication, QLabel + + from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget + + app = QApplication([]) + explorer = Explorer() + section = CollapsibleSection(title="SCRIPTS", indentation=0) + + script_explorer = Explorer() + script_widget = ScriptTreeWidget() + local_scripts_section = CollapsibleSection(title="Local") + local_scripts_section.set_widget(script_widget) + script_widget.set_directory(os.path.abspath("./")) + script_explorer.add_section(local_scripts_section) + + section.set_widget(script_explorer) + explorer.add_section(section) + shared_script_section = CollapsibleSection(title="Shared") + shared_script_widget = ScriptTreeWidget() + shared_script_widget.set_directory(os.path.abspath("./")) + shared_script_section.set_widget(shared_script_widget) + script_explorer.add_section(shared_script_section) + macros_section = CollapsibleSection(title="MACROS", indentation=0) + macros_section.set_widget(QLabel("Macros will be implemented later")) + explorer.add_section(macros_section) + explorer.show() + app.exec() diff --git a/bec_widgets/widgets/containers/explorer/script_tree_widget.py b/bec_widgets/widgets/containers/explorer/script_tree_widget.py new file mode 100644 index 00000000..86cec349 --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/script_tree_widget.py @@ -0,0 +1,387 @@ +import os +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 bec_widgets.utils.colors import get_theme_palette +from bec_widgets.utils.toolbars.actions import MaterialIconAction + +logger = bec_logger.logger + + +class FileItemDelegate(QStyledItemDelegate): + """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 add_file_action(self, action: QAction) -> None: + """Add an action for files""" + self.file_actions.append(action) + + def add_dir_action(self, action: QAction) -> None: + """Add an action for directories""" + self.dir_actions.append(action) + + def clear_actions(self) -> None: + """Remove all actions""" + 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 + if not isinstance(model, QSortFilterProxyModel): + return super().editorEvent(event, model, option, index) + + 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) + + 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 + + +class ScriptTreeWidget(QWidget): + """A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection""" + + file_selected = Signal(str) # Script file path selected + file_open_requested = Signal(str) # File open button clicked + file_renamed = Signal(str, str) # Old path, new 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) + + # Enable mouse tracking for hover effects + self.tree.setMouseTracking(True) + + # Create file system model + self.model = QFileSystemModel() + self.model.setNameFilters(["*.py"]) + self.model.setNameFilterDisables(False) + + # Create proxy model to filter out underscore directories + self.proxy_model = QSortFilterProxyModel() + self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*")) + self.proxy_model.setSourceModel(self.model) + self.tree.setModel(self.proxy_model) + + # Create and set custom delegate + self.delegate = FileItemDelegate(self.tree) + self.tree.setItemDelegate(self.delegate) + + # Add default open button for files + action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self) + action.action.triggered.connect(self._on_file_open_requested) + self.delegate.add_file_action(action.action) + + # Remove unnecessary columns + self.tree.setColumnHidden(1, True) # Hide size column + self.tree.setColumnHidden(2, True) # Hide type column + self.tree.setColumnHidden(3, True) # Hide date modified column + + # Apply BEC styling + self._apply_styling() + + # Script 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) + + # pylint: disable=f-string-without-interpolation + tree_style = f""" + QTreeView {{ + border: none; + outline: 0; + show-decoration-selected: 0; + }} + 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 scripts directory""" + self.directory = directory + + # Early return if directory doesn't exist + if not directory or not os.path.exists(directory): + return + + root_index = self.model.setRootPath(directory) + # Map the source model index to proxy model index + proxy_root_index = self.proxy_model.mapFromSource(root_index) + self.tree.setRootIndex(proxy_root_index) + self.tree.expandAll() + + def _on_item_clicked(self, index: QModelIndex): + """Handle item clicks""" + # Map proxy index back to source index + source_index = self.proxy_model.mapToSource(index) + + # Early return for directories + if self.model.isDir(source_index): + return + + file_path = self.model.filePath(source_index) + + # Early return if not a valid file + if not file_path or not os.path.isfile(file_path): + return + + path_obj = Path(file_path) + + # Only emit signal for Python files + if path_obj.suffix.lower() == ".py": + logger.info(f"Script selected: {file_path}") + self.file_selected.emit(file_path) + + def _on_item_double_clicked(self, index: QModelIndex): + """Handle item double-clicks""" + # Map proxy index back to source index + source_index = self.proxy_model.mapToSource(index) + + # Early return for directories + if self.model.isDir(source_index): + return + + file_path = self.model.filePath(source_index) + + # Early return if not a valid file + if not file_path or not os.path.isfile(file_path): + return + + # Emit signal to open the file + logger.info(f"File open requested via double-click: {file_path}") + self.file_open_requested.emit(file_path) + + def _on_file_open_requested(self): + """Handle file open action triggered""" + logger.info("File open requested") + # Early return if no hovered item + if not self.delegate.hovered_index.isValid(): + return + + source_index = self.proxy_model.mapToSource(self.delegate.hovered_index) + file_path = self.model.filePath(source_index) + + # Early return if not a valid file + if not file_path or not os.path.isfile(file_path): + return + + self.file_open_requested.emit(file_path) + + def add_file_action(self, action: QAction) -> None: + """Add an action for file items""" + self.delegate.add_file_action(action) + + def add_dir_action(self, action: QAction) -> None: + """Add an action for directory items""" + self.delegate.add_dir_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.model.setRootPath("") # Reset + root_index = self.model.setRootPath(self.directory) + proxy_root_index = self.proxy_model.mapFromSource(root_index) + self.tree.setRootIndex(proxy_root_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/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py new file mode 100644 index 00000000..38a5b274 --- /dev/null +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -0,0 +1,146 @@ +import datetime +import importlib +import os + +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.script_tree_widget import ScriptTreeWidget + + +class IDEExplorer(BECWidget, QWidget): + """Integrated Development Environment Explorer""" + + PLUGIN = True + RPC = False + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + self._sections = 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"] + + @SafeProperty(list) + def sections(self): + return list(self._sections) + + @sections.setter + def sections(self, value): + existing_sections = set(self._sections) + self._sections = set(value) + self._update_section_visibility(self._sections - existing_sections) + + def _update_section_visibility(self, sections): + for section in sections: + self._add_section(section) + + def _add_section(self, section_name): + match section_name.lower(): + case "scripts": + self.add_script_section() + case _: + pass + + def add_script_section(self): + section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0) + section.expanded = False + + script_explorer = Explorer(parent=self) + script_widget = ScriptTreeWidget(parent=self) + 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) + local_script_dir = self.client._service_config.model.user_scripts.base_path + if not os.path.exists(local_script_dir): + os.makedirs(local_script_dir) + script_widget.set_directory(local_script_dir) + script_explorer.add_section(local_scripts_section) + + 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 + + if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir): + return + shared_script_section = CollapsibleSection(title="Shared", parent=self) + 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) + + def _add_local_script(self): + """Show a dialog to enter the name of a new script and create it.""" + + target_section = self.main_explorer.get_section("SCRIPTS") + script_dir_section = target_section.content_widget.get_section("Local") + + local_script_dir = script_dir_section.content_widget.directory + + # Prompt user for filename + filename, ok = QInputDialog.getText( + self, "New Script", f"Enter script name ({local_script_dir}/):" + ) + + if not ok or not filename: + return # User cancelled or didn't enter a name + + # Add .py extension if not already present + if not filename.endswith(".py"): + filename = f"{filename}.py" + + file_path = os.path.join(local_script_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 basic template + with open(file_path, "w", encoding="utf-8") as f: + f.write( + f""" +\"\"\" +{filename} - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +\"\"\" +""" + ) + + except Exception as e: + # Show error if file creation failed + QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}") + + +if __name__ == "__main__": + from qtpy.QtWidgets import QApplication + + app = QApplication([]) + script_explorer = IDEExplorer() + script_explorer.show() + app.exec_() diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.pyproject b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.pyproject new file mode 100644 index 00000000..db1334eb --- /dev/null +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.pyproject @@ -0,0 +1 @@ +{'files': ['ide_explorer.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py new file mode 100644 index 00000000..ce99a35e --- /dev/null +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer_plugin.py @@ -0,0 +1,54 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + +DOM_XML = """ + + + + +""" + + +class IDEExplorerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = IDEExplorer(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(IDEExplorer.ICON_NAME) + + def includeFile(self): + return "ide_explorer" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "IDEExplorer" + + def toolTip(self): + return "Integrated Development Environment Explorer" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/utility/ide_explorer/register_ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/register_ide_explorer.py new file mode 100644 index 00000000..4a92443e --- /dev/null +++ b/bec_widgets/widgets/utility/ide_explorer/register_ide_explorer.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.utility.ide_explorer.ide_explorer_plugin import IDEExplorerPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(IDEExplorerPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/tests/unit_tests/test_explorer.py b/tests/unit_tests/test_explorer.py new file mode 100644 index 00000000..f911cf23 --- /dev/null +++ b/tests/unit_tests/test_explorer.py @@ -0,0 +1,55 @@ +import pytest + +from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection +from bec_widgets.widgets.containers.explorer.explorer import Explorer + + +@pytest.fixture +def explorer(qtbot): + widget = Explorer() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_explorer_initialization(explorer): + assert explorer is not None + assert len(explorer.sections) == 0 + + +def test_add_remove_section(explorer, qtbot): + section = CollapsibleSection(title="Test Section", parent=explorer) + explorer.add_section(section) + assert len(explorer.sections) == 1 + assert explorer.sections[0].title == "Test Section" + + section2 = CollapsibleSection(title="Another Section", parent=explorer) + explorer.add_section(section2) + assert len(explorer.sections) == 2 + assert explorer.sections[1].title == "Another Section" + + explorer.remove_section(section) + assert len(explorer.sections) == 1 + assert explorer.sections[0].title == "Another Section" + qtbot.wait(100) # Allow time for the section to be removed + assert explorer.splitter.count() == 1 + + +def test_section_reorder(explorer): + section = CollapsibleSection(title="Section 1", parent=explorer) + explorer.add_section(section) + + section2 = CollapsibleSection(title="Section 2", parent=explorer) + explorer.add_section(section2) + + assert explorer.sections[0].title == "Section 1" + assert explorer.sections[1].title == "Section 2" + assert len(explorer.sections) == 2 + assert explorer.splitter.count() == 2 + + explorer._handle_section_reorder("Section 1", "Section 2") + + assert explorer.sections[0].title == "Section 2" + assert explorer.sections[1].title == "Section 1" + assert len(explorer.sections) == 2 + assert explorer.splitter.count() == 2 diff --git a/tests/unit_tests/test_ide_explorer.py b/tests/unit_tests/test_ide_explorer.py new file mode 100644 index 00000000..ba1b9eec --- /dev/null +++ b/tests/unit_tests/test_ide_explorer.py @@ -0,0 +1,36 @@ +import os +from unittest import mock + +import pytest + +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + + +@pytest.fixture +def ide_explorer(qtbot, tmpdir): + """Create an IDEExplorer widget for testing""" + widget = IDEExplorer() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_ide_explorer_initialization(ide_explorer): + """Test the initialization of the IDEExplorer widget""" + assert ide_explorer is not None + assert "scripts" in ide_explorer.sections + assert ide_explorer.main_explorer.sections[0].title == "SCRIPTS" + + +def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir): + local_script_section = ide_explorer.main_explorer.get_section( + "SCRIPTS" + ).content_widget.get_section("Local") + local_script_section.content_widget.set_directory(str(tmpdir)) + + with mock.patch( + "bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText", + return_value=("test_file.py", True), + ): + ide_explorer._add_local_script() + assert os.path.exists(os.path.join(tmpdir, "test_file.py")) diff --git a/tests/unit_tests/test_script_tree_widget.py b/tests/unit_tests/test_script_tree_widget.py new file mode 100644 index 00000000..e69ccae7 --- /dev/null +++ b/tests/unit_tests/test_script_tree_widget.py @@ -0,0 +1,118 @@ +from pathlib import Path + +import pytest +from qtpy.QtCore import QEvent, Qt +from qtpy.QtGui import QMouseEvent + +from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget + + +@pytest.fixture +def script_tree(qtbot, tmpdir): + """Create a ScriptTreeWidget with the tmpdir directory""" + # Create test files and directories + (Path(tmpdir) / "test_file.py").touch() + (Path(tmpdir) / "test_dir").mkdir() + (Path(tmpdir) / "test_dir" / "nested_file.py").touch() + + widget = ScriptTreeWidget() + widget.set_directory(str(tmpdir)) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_script_tree_set_directory(script_tree, tmpdir): + """Test setting the directory""" + assert script_tree.directory == str(tmpdir) + + +def test_script_tree_hover_events(script_tree, qtbot): + """Test mouse hover events and actions button visibility""" + + # Get the tree view and its viewport + tree_view = script_tree.tree + viewport = tree_view.viewport() + + # Find the position of the first item (test_file.py) + index = script_tree.proxy_model.index(0, 0) # first item + rect = tree_view.visualRect(index) + pos = rect.center() + + # Initially, no item should be hovered + assert script_tree.delegate.hovered_index.isValid() == False + + # 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 (the event filter is installed on the viewport) + script_tree.eventFilter(viewport, mouse_event) + + qtbot.wait(100) # Allow time for the hover to be processed + + # Now, the hover index should be set to the first item + assert script_tree.delegate.hovered_index.isValid() == True + assert script_tree.delegate.hovered_index.row() == index.row() + + # Simulate mouse leaving the viewport + leave_event = QEvent(QEvent.Type.Leave) + script_tree.eventFilter(viewport, leave_event) + + qtbot.wait(100) # Allow time for the leave event to be processed + + # After leaving, no item should be hovered + assert script_tree.delegate.hovered_index.isValid() == False + + +@pytest.mark.timeout(10) +def test_script_tree_on_item_clicked(script_tree, qtbot, tmpdir): + """Test that _on_item_clicked emits file_selected signal only for Python files""" + + file_selected_signals = [] + file_open_requested_signals = [] + + def on_file_selected(file_path): + file_selected_signals.append(file_path) + + def on_file_open_requested(file_path): + file_open_requested_signals.append(file_path) + + # Connect to the signal + script_tree.file_selected.connect(on_file_selected) + script_tree.file_open_requested.connect(on_file_open_requested) + + # Wait until the model sees test_file.py + def has_py_file(): + nonlocal py_file_index + root_index = script_tree.tree.rootIndex() + for i in range(script_tree.proxy_model.rowCount(root_index)): + index = script_tree.proxy_model.index(i, 0, root_index) + source_index = script_tree.proxy_model.mapToSource(index) + if script_tree.model.fileName(source_index) == "test_file.py": + py_file_index = index + return True + return False + + py_file_index = None + qtbot.waitUntil(has_py_file) + + # Simulate clicking on the center of the item + script_tree._on_item_clicked(py_file_index) + qtbot.wait(100) # Allow time for the click to be processed + + py_file_index = None + qtbot.waitUntil(has_py_file) + + script_tree._on_item_double_clicked(py_file_index) + qtbot.wait(100) + + # Verify the signal was emitted with the correct path + assert len(file_selected_signals) == 1 + assert Path(file_selected_signals[0]).name == "test_file.py"