From 7a43111de06fab75d3f6d3a9207973f5beb9b482 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 18 Aug 2025 16:47:16 +0200 Subject: [PATCH] feat: add developer view --- .../examples/developer_view/__init__.py | 0 .../examples/developer_view/developer_view.py | 433 ++++++++++++++++++ .../widgets/containers/explorer/explorer.py | 4 +- .../containers/explorer/macro_tree_widget.py | 408 +++++++++++++++++ .../containers/explorer/script_tree_widget.py | 29 +- .../widgets/editors/monaco/monaco_tab.py | 393 ++++++++++++++++ .../widgets/editors/monaco/monaco_widget.py | 115 ++++- .../editors/monaco/scan_control_dialog.py | 145 ++++++ .../utility/ide_explorer/ide_explorer.py | 191 +++++++- tests/unit_tests/test_tree_widget.py | 22 + 10 files changed, 1717 insertions(+), 23 deletions(-) create mode 100644 bec_widgets/examples/developer_view/__init__.py create mode 100644 bec_widgets/examples/developer_view/developer_view.py create mode 100644 bec_widgets/widgets/containers/explorer/macro_tree_widget.py create mode 100644 bec_widgets/widgets/editors/monaco/monaco_tab.py create mode 100644 bec_widgets/widgets/editors/monaco/scan_control_dialog.py create mode 100644 tests/unit_tests/test_tree_widget.py diff --git a/bec_widgets/examples/developer_view/__init__.py b/bec_widgets/examples/developer_view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/examples/developer_view/developer_view.py b/bec_widgets/examples/developer_view/developer_view.py new file mode 100644 index 00000000..01f9dfbc --- /dev/null +++ b/bec_widgets/examples/developer_view/developer_view.py @@ -0,0 +1,433 @@ +import re +from typing import List + +import markdown +import PySide6QtAds as QtAds +from bec_lib.endpoints import MessageEndpoints +from bec_lib.script_executor import upload_script +from bec_qthemes import material_icon +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtCore import Qt, QTimer +from qtpy.QtGui import QKeySequence, QShortcut +from qtpy.QtWidgets import QSplitter, QTextEdit, QVBoxLayout, QWidget + +from bec_widgets import BECWidget +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle +from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea +from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock +from bec_widgets.widgets.editors.web_console.web_console import WebConsole +from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer + + +def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None: + """ + Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1]. + Works for horizontal or vertical splitters and sets matching stretch factors. + """ + + def apply(): + n = splitter.count() + if n == 0: + return + w = list(weights[:n]) + [1] * max(0, n - len(weights)) + w = [max(0.0, float(x)) for x in w] + tot_w = sum(w) + if tot_w <= 0: + w = [1.0] * n + tot_w = float(n) + total_px = ( + splitter.width() if splitter.orientation() == Qt.Horizontal else splitter.height() + ) + if total_px < 2: + QTimer.singleShot(0, apply) + return + sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w] + diff = total_px - sum(sizes) + if diff != 0: + idx = max(range(n), key=lambda i: w[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, wi in enumerate(w): + splitter.setStretchFactor(i, max(1, int(round(wi * 100)))) + + QTimer.singleShot(0, apply) + + +def markdown_to_html(md_text: str) -> str: + """Convert Markdown with syntax highlighting to HTML (Qt-compatible).""" + + # Preprocess: convert consecutive >>> lines to Python code blocks + def replace_python_examples(match): + indent = match.group(1) + examples = match.group(2) + # Remove >>> prefix and clean up the code + lines = [] + for line in examples.strip().split("\n"): + line = line.strip() + if line.startswith(">>> "): + lines.append(line[4:]) # Remove '>>> ' + elif line.startswith(">>>"): + lines.append(line[3:]) # Remove '>>>' + code = "\n".join(lines) + + return f"{indent}```python\n{indent}{code}\n{indent}```" + + # Match one or more consecutive >>> lines (with same indentation) + pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)" + md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE) + + extensions = ["fenced_code", "codehilite", "tables", "sane_lists"] + html = markdown.markdown( + md_text, + extensions=extensions, + extension_configs={ + "codehilite": {"linenums": False, "guess_lang": False, "noclasses": True} + }, + output_format="html", + ) + + # Remove hardcoded background colors that conflict with themes + html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html) + html = re.sub(r"background: #[^;]*;", "", html) + + # Add CSS to force code blocks to wrap + css = """ + + """ + + return css + html + + +class DeveloperView(BECWidget, QWidget): + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + self.toolbar = ModularToolBar(self) + self.init_developer_toolbar() + self._root_layout.addWidget(self.toolbar) + + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self._root_layout.addWidget(self.dock_manager) + + # Initialize the widgets + self.explorer = IDEExplorer(self) + self.console = WebConsole(self) + self.terminal = WebConsole(self, startup_cmd="") + self.monaco = MonacoDock(self) + self.monaco.save_enabled.connect(self._on_save_enabled_update) + self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom") + self.signature_help = QTextEdit(self) + self.signature_help.setAcceptRichText(True) + self.signature_help.setReadOnly(True) + self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) + opt = self.signature_help.document().defaultTextOption() + opt.setWrapMode(opt.WrapMode.WrapAnywhere) + self.signature_help.document().setDefaultTextOption(opt) + self.monaco.signature_help.connect( + lambda text: self.signature_help.setHtml(markdown_to_html(text)) + ) + + # Create the dock widgets + self.explorer_dock = QtAds.CDockWidget("Explorer", self) + self.explorer_dock.setWidget(self.explorer) + + self.console_dock = QtAds.CDockWidget("Console", self) + self.console_dock.setWidget(self.console) + + self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self) + self.monaco_dock.setWidget(self.monaco) + + self.terminal_dock = QtAds.CDockWidget("Terminal", self) + self.terminal_dock.setWidget(self.terminal) + + # Monaco will be central widget + self.dock_manager.setCentralWidget(self.monaco_dock) + + # Add the dock widgets to the dock manager + area_bottom = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock + ) + self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom) + + area_left = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock + ) + area_left.titleBar().setVisible(False) + + for dock in self.dock_manager.dockWidgets(): + # dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea + # dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same + dock.setFeature(CDockWidget.DockWidgetClosable, False) + dock.setFeature(CDockWidget.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetMovable, False) + + self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self) + self.plotting_ads_dock.setWidget(self.plotting_ads) + + self.signature_dock = QtAds.CDockWidget("Signature Help", self) + self.signature_dock.setWidget(self.signature_help) + + area_right = self.dock_manager.addDockWidget( + QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock + ) + self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right) + + # Apply stretch after the layout is done + self.set_default_view([2, 5, 3], [7, 3]) + + # Connect editor signals + self.explorer.file_open_requested.connect(self._open_new_file) + + self.toolbar.show_bundles(["save", "execution", "settings"]) + + def init_developer_toolbar(self): + """Initialize the developer toolbar with necessary actions and widgets.""" + save_button = MaterialIconAction( + icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self + ) + save_button.action.triggered.connect(self.on_save) + self.toolbar.components.add_safe("save", save_button) + + save_as_button = MaterialIconAction( + icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self + ) + self.toolbar.components.add_safe("save_as", save_as_button) + + save_bundle = ToolbarBundle("save", self.toolbar.components) + save_bundle.add_action("save") + save_bundle.add_action("save_as") + self.toolbar.add_bundle(save_bundle) + + run_action = MaterialIconAction( + icon_name="play_arrow", + tooltip="Run current file", + label_text="Run", + filled=True, + parent=self, + ) + run_action.action.triggered.connect(self.on_execute) + self.toolbar.components.add_safe("run", run_action) + + stop_action = MaterialIconAction( + icon_name="stop", + tooltip="Stop current execution", + label_text="Stop", + filled=True, + parent=self, + ) + stop_action.action.triggered.connect(self.on_stop) + self.toolbar.components.add_safe("stop", stop_action) + + execution_bundle = ToolbarBundle("execution", self.toolbar.components) + execution_bundle.add_action("run") + execution_bundle.add_action("stop") + self.toolbar.add_bundle(execution_bundle) + + vim_action = MaterialIconAction( + icon_name="vim", + tooltip="Toggle Vim Mode", + label_text="Vim", + filled=True, + parent=self, + checkable=True, + ) + self.toolbar.components.add_safe("vim", vim_action) + vim_action.action.triggered.connect(self.on_vim_triggered) + + settings_bundle = ToolbarBundle("settings", self.toolbar.components) + settings_bundle.add_action("vim") + self.toolbar.add_bundle(settings_bundle) + + save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self) + save_shortcut.activated.connect(self.on_save) + save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self) + save_as_shortcut.activated.connect(self.on_save_as) + + ####### Default view has to be done with setting up splitters ######## + def set_default_view(self, horizontal_weights: list, vertical_weights: list): + """Apply initial weights to every horizontal and vertical splitter. + + Examples: + horizontal_weights = [1, 3, 2, 1] + vertical_weights = [3, 7] # top:bottom = 30:70 + """ + splitters_h = [] + splitters_v = [] + for splitter in self.findChildren(QSplitter): + if splitter.orientation() == Qt.Horizontal: + splitters_h.append(splitter) + elif splitter.orientation() == Qt.Vertical: + splitters_v.append(splitter) + + def apply_all(): + for s in splitters_h: + set_splitter_weights(s, horizontal_weights) + for s in splitters_v: + set_splitter_weights(s, vertical_weights) + + QTimer.singleShot(0, apply_all) + + def set_stretch(self, *, horizontal=None, vertical=None): + """Update splitter weights and re-apply to all splitters. + + Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict + for convenience: horizontal roles = {"left","center","right"}, + vertical roles = {"top","bottom"}. + """ + + def _coerce_h(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [ + float(x.get("left", 1)), + float(x.get("center", x.get("middle", 1))), + float(x.get("right", 1)), + ] + return None + + def _coerce_v(x): + if x is None: + return None + if isinstance(x, (list, tuple)): + return list(map(float, x)) + if isinstance(x, dict): + return [float(x.get("top", 1)), float(x.get("bottom", 1))] + return None + + h = _coerce_h(horizontal) + v = _coerce_v(vertical) + if h is None: + h = [1, 1, 1] + if v is None: + v = [1, 1] + self.set_default_view(h, v) + + def _open_new_file(self, file_name: str, scope: str): + self.monaco.open_file(file_name) + + # Set read-only mode for shared files + if "shared" in scope: + self.monaco.set_file_readonly(file_name, True) + + # Add appropriate icon based on file type + if "script" in scope: + # Use script icon for script files + icon = material_icon("script", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + elif "macro" in scope: + # Use function icon for macro files + icon = material_icon("function", size=(24, 24)) + self.monaco.set_file_icon(file_name, icon) + + @SafeSlot() + def on_save(self): + self.monaco.save_file() + + @SafeSlot() + def on_save_as(self): + self.monaco.save_file(force_save_as=True) + + @SafeSlot() + def on_vim_triggered(self): + self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked()) + + @SafeSlot(bool) + def _on_save_enabled_update(self, enabled: bool): + self.toolbar.components.get_action("save").action.setEnabled(enabled) + self.toolbar.components.get_action("save_as").action.setEnabled(enabled) + + @SafeSlot() + def on_execute(self): + self.script_editor_tab = self.monaco.last_focused_editor + if not self.script_editor_tab: + return + self.current_script_id = upload_script( + self.client.connector, self.script_editor_tab.widget().get_text() + ) + self.console.write(f'bec._run_script("{self.current_script_id}")') + print(f"Uploaded script with ID: {self.current_script_id}") + + @SafeSlot() + def on_stop(self): + print("Stopping execution...") + + @property + def current_script_id(self): + return self._current_script_id + + @current_script_id.setter + def current_script_id(self, value): + if not isinstance(value, str): + raise ValueError("Script ID must be a string.") + self._current_script_id = value + self._update_subscription() + + def _update_subscription(self): + if self.current_script_id: + self.bec_dispatcher.connect_slot( + self.on_script_execution_info, + MessageEndpoints.script_execution_info(self.current_script_id), + ) + else: + self.bec_dispatcher.disconnect_slot( + self.on_script_execution_info, + MessageEndpoints.script_execution_info(self.current_script_id), + ) + + @SafeSlot(dict, dict) + def on_script_execution_info(self, content: dict, metadata: dict): + print(f"Script execution info: {content}") + current_lines = content.get("current_lines") + if not current_lines: + self.script_editor_tab.widget().clear_highlighted_lines() + return + line_number = current_lines[0] + self.script_editor_tab.widget().clear_highlighted_lines() + self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number) + + +if __name__ == "__main__": + import sys + + from bec_qthemes import apply_theme + from qtpy.QtWidgets import QApplication + + from bec_widgets.applications.main_app import BECMainApp + + app = QApplication(sys.argv) + apply_theme("dark") + + _app = BECMainApp() + developer_view = DeveloperView() + _app.add_view( + icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True + ) + _app.show() + # developer_view.show() + # developer_view.setWindowTitle("Developer View") + # developer_view.resize(1920, 1080) + # developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/containers/explorer/explorer.py b/bec_widgets/widgets/containers/explorer/explorer.py index b780cbde..25bff357 100644 --- a/bec_widgets/widgets/containers/explorer/explorer.py +++ b/bec_widgets/widgets/containers/explorer/explorer.py @@ -18,8 +18,8 @@ class Explorer(BECWidget, QWidget): RPC = False PLUGIN = False - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) # Main layout self.main_layout = QVBoxLayout(self) diff --git a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py new file mode 100644 index 00000000..3b247d97 --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py @@ -0,0 +1,408 @@ +import ast +import os +from pathlib import Path +from typing import Any + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QModelIndex, QRect, Qt, Signal +from qtpy.QtGui import QPainter, QStandardItem, QStandardItemModel +from qtpy.QtWidgets import QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget + +from bec_widgets.utils.colors import get_theme_palette +from bec_widgets.utils.toolbars.actions import MaterialIconAction + +logger = bec_logger.logger + + +class MacroItemDelegate(QStyledItemDelegate): + """Custom delegate to show action buttons on hover for macro functions""" + + def __init__(self, parent=None): + super().__init__(parent) + self.hovered_index = QModelIndex() + self.macro_actions: list[Any] = [] + self.button_rects: list[QRect] = [] + self.current_macro_info = {} + + def add_macro_action(self, action: Any) -> None: + """Add an action for macro functions""" + self.macro_actions.append(action) + + def clear_actions(self) -> None: + """Remove all actions""" + self.macro_actions.clear() + + def paint(self, painter, option, index): + """Paint the item with action buttons on hover""" + # Paint the default item + super().paint(painter, option, index) + + # Early return if not hovering over this item + if index != self.hovered_index: + return + + # Only show actions for macro functions (not directories) + item = index.model().itemFromIndex(index) + if not item or not item.data(Qt.ItemDataRole.UserRole): + return + + macro_info = item.data(Qt.ItemDataRole.UserRole) + if not isinstance(macro_info, dict) or "function_name" not in macro_info: + return + + self.current_macro_info = macro_info + + if self.macro_actions: + self._draw_action_buttons(painter, option, self.macro_actions) + + def _draw_action_buttons(self, painter, option, actions: list[Any]): + """Draw action buttons on the right side""" + button_size = 18 + margin = 4 + spacing = 2 + + # Calculate total width needed for all buttons + total_width = len(actions) * button_size + (len(actions) - 1) * spacing + + # Clear previous button rects and create new ones + self.button_rects.clear() + + # Calculate starting position (right side of the item) + start_x = option.rect.right() - total_width - margin + current_x = start_x + + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Get theme colors for better integration + palette = get_theme_palette() + button_bg = palette.button().color() + button_bg.setAlpha(150) # Semi-transparent + + for action in actions: + if not action.isVisible(): + continue + + # Calculate button position + button_rect = QRect( + current_x, + option.rect.top() + (option.rect.height() - button_size) // 2, + button_size, + button_size, + ) + self.button_rects.append(button_rect) + + # Draw button background + painter.setBrush(button_bg) + painter.setPen(palette.mid().color()) + painter.drawRoundedRect(button_rect, 3, 3) + + # Draw action icon + icon = action.icon() + if not icon.isNull(): + icon_rect = button_rect.adjusted(2, 2, -2, -2) + icon.paint(painter, icon_rect) + + # Move to next button position + current_x += button_size + spacing + + painter.restore() + + def editorEvent(self, event, model, option, index): + """Handle mouse events for action buttons""" + # Early return if not a left click + if not ( + event.type() == event.Type.MouseButtonPress + and event.button() == Qt.MouseButton.LeftButton + ): + return super().editorEvent(event, model, option, index) + + # Check which button was clicked + visible_actions = [action for action in self.macro_actions if action.isVisible()] + for i, button_rect in enumerate(self.button_rects): + if button_rect.contains(event.pos()) and i < len(visible_actions): + # Trigger the action + visible_actions[i].trigger() + return True + + return super().editorEvent(event, model, option, index) + + def set_hovered_index(self, index): + """Set the currently hovered index""" + self.hovered_index = index + + +class MacroTreeWidget(QWidget): + """A tree widget that displays macro functions from Python files""" + + macro_selected = Signal(str, str) # Function name, file path + macro_open_requested = Signal(str, str) # Function name, file path + + def __init__(self, parent=None): + super().__init__(parent) + + # Create layout + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Create tree view + self.tree = QTreeView() + self.tree.setHeaderHidden(True) + self.tree.setRootIsDecorated(True) + + # Disable editing to prevent renaming on double-click + self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers) + + # Enable mouse tracking for hover effects + self.tree.setMouseTracking(True) + + # Create model for macro functions + self.model = QStandardItemModel() + self.tree.setModel(self.model) + + # Create and set custom delegate + self.delegate = MacroItemDelegate(self.tree) + self.tree.setItemDelegate(self.delegate) + + # Add default open button for macros + action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self) + action.action.triggered.connect(self._on_macro_open_requested) + self.delegate.add_macro_action(action.action) + + # Apply BEC styling + self._apply_styling() + + # Macro specific properties + self.directory = None + + # Connect signals + self.tree.clicked.connect(self._on_item_clicked) + self.tree.doubleClicked.connect(self._on_item_double_clicked) + + # Install event filter for hover tracking + self.tree.viewport().installEventFilter(self) + + # Add to layout + layout.addWidget(self.tree) + + def _apply_styling(self): + """Apply styling to the tree widget""" + # Get theme colors for subtle tree lines + palette = get_theme_palette() + subtle_line_color = palette.mid().color() + subtle_line_color.setAlpha(80) + + # Standard editable styling + opacity_modifier = "" + cursor_style = "" + + # pylint: disable=f-string-without-interpolation + tree_style = f""" + QTreeView {{ + border: none; + outline: 0; + show-decoration-selected: 0; + {opacity_modifier} + {cursor_style} + }} + QTreeView::branch {{ + border-image: none; + background: transparent; + }} + + QTreeView::item {{ + border: none; + padding: 0px; + margin: 0px; + }} + QTreeView::item:hover {{ + background: palette(midlight); + border: none; + padding: 0px; + margin: 0px; + text-decoration: none; + }} + QTreeView::item:selected {{ + background: palette(highlight); + color: palette(highlighted-text); + }} + QTreeView::item:selected:hover {{ + background: palette(highlight); + }} + """ + + self.tree.setStyleSheet(tree_style) + + def eventFilter(self, obj, event): + """Handle mouse move events for hover tracking""" + # Early return if not the tree viewport + if obj != self.tree.viewport(): + return super().eventFilter(obj, event) + + if event.type() == event.Type.MouseMove: + index = self.tree.indexAt(event.pos()) + if index.isValid(): + self.delegate.set_hovered_index(index) + else: + self.delegate.set_hovered_index(QModelIndex()) + self.tree.viewport().update() + return super().eventFilter(obj, event) + + if event.type() == event.Type.Leave: + self.delegate.set_hovered_index(QModelIndex()) + self.tree.viewport().update() + return super().eventFilter(obj, event) + + return super().eventFilter(obj, event) + + def set_directory(self, directory): + """Set the macros directory and scan for macro functions""" + self.directory = directory + + # Early return if directory doesn't exist + if not directory or not os.path.exists(directory): + return + + self._scan_macro_functions() + + def _scan_macro_functions(self): + """Scan the directory for Python files and extract macro functions""" + self.model.clear() + self.model.setHorizontalHeaderLabels(["Macros"]) + + if not self.directory or not os.path.exists(self.directory): + return + + # Get all Python files in the directory + python_files = list(Path(self.directory).glob("*.py")) + + for py_file in python_files: + # Skip files starting with underscore + if py_file.name.startswith("_"): + continue + + try: + functions = self._extract_functions_from_file(py_file) + if functions: + # Create a file node + file_item = QStandardItem(py_file.stem) + file_item.setData( + {"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole + ) + + # Add function nodes + for func_name, func_info in functions.items(): + func_item = QStandardItem(func_name) + func_data = { + "function_name": func_name, + "file_path": str(py_file), + "line_number": func_info.get("line_number", 1), + "type": "function", + } + func_item.setData(func_data, Qt.ItemDataRole.UserRole) + file_item.appendRow(func_item) + + self.model.appendRow(file_item) + except Exception as e: + logger.warning(f"Failed to parse {py_file}: {e}") + + self.tree.expandAll() + + def _extract_functions_from_file(self, file_path: Path) -> dict: + """Extract function definitions from a Python file""" + functions = {} + + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Parse the AST + tree = ast.parse(content) + + # Only get top-level function definitions + for node in tree.body: + if isinstance(node, ast.FunctionDef): + functions[node.name] = { + "line_number": node.lineno, + "docstring": ast.get_docstring(node) or "", + } + + except Exception as e: + logger.warning(f"Failed to parse {file_path}: {e}") + + return functions + + def _on_item_clicked(self, index: QModelIndex): + """Handle item clicks""" + item = self.model.itemFromIndex(index) + if not item: + return + + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + + if data.get("type") == "function": + function_name = data.get("function_name") + file_path = data.get("file_path") + if function_name and file_path: + logger.info(f"Macro function selected: {function_name} in {file_path}") + self.macro_selected.emit(function_name, file_path) + + def _on_item_double_clicked(self, index: QModelIndex): + """Handle item double-clicks""" + item = self.model.itemFromIndex(index) + if not item: + return + + data = item.data(Qt.ItemDataRole.UserRole) + if not data: + return + + if data.get("type") == "function": + function_name = data.get("function_name") + file_path = data.get("file_path") + if function_name and file_path: + logger.info( + f"Macro open requested via double-click: {function_name} in {file_path}" + ) + self.macro_open_requested.emit(function_name, file_path) + + def _on_macro_open_requested(self): + """Handle macro open action triggered""" + logger.info("Macro open requested") + # Early return if no hovered item + if not self.delegate.hovered_index.isValid(): + return + + macro_info = self.delegate.current_macro_info + if not macro_info or macro_info.get("type") != "function": + return + + function_name = macro_info.get("function_name") + file_path = macro_info.get("file_path") + if function_name and file_path: + self.macro_open_requested.emit(function_name, file_path) + + def add_macro_action(self, action: Any) -> None: + """Add an action for macro items""" + self.delegate.add_macro_action(action) + + def clear_actions(self) -> None: + """Remove all actions from items""" + self.delegate.clear_actions() + + def refresh(self): + """Refresh the tree view""" + if self.directory is None: + return + self._scan_macro_functions() + + def expand_all(self): + """Expand all items in the tree""" + self.tree.expandAll() + + def collapse_all(self): + """Collapse all items in the tree""" + self.tree.collapseAll() diff --git a/bec_widgets/widgets/containers/explorer/script_tree_widget.py b/bec_widgets/widgets/containers/explorer/script_tree_widget.py index 86cec349..6c8ed5c8 100644 --- a/bec_widgets/widgets/containers/explorer/script_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/script_tree_widget.py @@ -3,7 +3,7 @@ from 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.QtGui import QPainter from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget from bec_widgets.utils.colors import get_theme_palette @@ -15,19 +15,20 @@ logger = bec_logger.logger class FileItemDelegate(QStyledItemDelegate): """Custom delegate to show action buttons on hover""" - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, tree_widget): + super().__init__(tree_widget) + self.setObjectName("file_item_delegate") self.hovered_index = QModelIndex() - self.file_actions: list[QAction] = [] - self.dir_actions: list[QAction] = [] - self.button_rects: list[QRect] = [] + self.file_actions = [] + self.dir_actions = [] + self.button_rects = [] self.current_file_path = "" - def add_file_action(self, action: QAction) -> None: + def add_file_action(self, action) -> None: """Add an action for files""" self.file_actions.append(action) - def add_dir_action(self, action: QAction) -> None: + def add_dir_action(self, action) -> None: """Add an action for directories""" self.dir_actions.append(action) @@ -67,7 +68,7 @@ class FileItemDelegate(QStyledItemDelegate): if actions: self._draw_action_buttons(painter, option, actions) - def _draw_action_buttons(self, painter, option, actions: list[QAction]): + def _draw_action_buttons(self, painter, option, actions): """Draw action buttons on the right side""" button_size = 18 margin = 4 @@ -229,12 +230,18 @@ class ScriptTreeWidget(QWidget): subtle_line_color = palette.mid().color() subtle_line_color.setAlpha(80) + # Standard editable styling + opacity_modifier = "" + cursor_style = "" + # pylint: disable=f-string-without-interpolation tree_style = f""" QTreeView {{ border: none; outline: 0; show-decoration-selected: 0; + {opacity_modifier} + {cursor_style} }} QTreeView::branch {{ border-image: none; @@ -357,11 +364,11 @@ class ScriptTreeWidget(QWidget): self.file_open_requested.emit(file_path) - def add_file_action(self, action: QAction) -> None: + def add_file_action(self, action) -> None: """Add an action for file items""" self.delegate.add_file_action(action) - def add_dir_action(self, action: QAction) -> None: + def add_dir_action(self, action) -> None: """Add an action for directory items""" self.delegate.add_dir_action(action) diff --git a/bec_widgets/widgets/editors/monaco/monaco_tab.py b/bec_widgets/widgets/editors/monaco/monaco_tab.py new file mode 100644 index 00000000..d4b62e78 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/monaco_tab.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import os +import pathlib +from typing import Any, cast + +import PySide6QtAds as QtAds +from bec_lib.logger import bec_logger +from PySide6QtAds import CDockWidget +from qtpy.QtCore import QEvent, QTimer, Signal +from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget + +from bec_widgets import BECWidget +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +logger = bec_logger.logger + + +class MonacoDock(BECWidget, QWidget): + """ + MonacoDock is a dock widget that contains Monaco editor instances. + It is used to manage multiple Monaco editors in a dockable interface. + """ + + focused_editor = Signal(object) # Emitted when the focused editor changes + save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled + signature_help = Signal(str) # Emitted when signature help is requested + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + self.dock_manager = QtAds.CDockManager(self) + self.dock_manager.setStyleSheet("") + self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event) + self._root_layout.addWidget(self.dock_manager) + self.dock_manager.installEventFilter(self) + self._last_focused_editor: MonacoWidget | None = None + self.focused_editor.connect(self._on_last_focused_editor_changed) + self.add_editor() + self._open_files = {} + + def _create_editor(self): + widget = MonacoWidget(self) + widget.save_enabled.connect(self.save_enabled.emit) + widget.editor.signature_help_triggered.connect(self._on_signature_change) + count = len(self.dock_manager.dockWidgets()) + dock = CDockWidget(f"Untitled_{count + 1}") + dock.setWidget(widget) + + # Connect to modification status changes to update tab titles + widget.save_enabled.connect( + lambda modified: self._update_tab_title_for_modification(dock, modified) + ) + + dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True) + dock.setFeature(CDockWidget.CustomCloseHandling, True) + dock.setFeature(CDockWidget.DockWidgetClosable, True) + dock.setFeature(CDockWidget.DockWidgetFloatable, False) + dock.setFeature(CDockWidget.DockWidgetMovable, True) + + dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget)) + + return dock + + @property + def last_focused_editor(self) -> CDockWidget | None: + """ + Get the last focused editor. + """ + return self._last_focused_editor + + @last_focused_editor.setter + def last_focused_editor(self, editor: CDockWidget | None): + self._last_focused_editor = editor + self.focused_editor.emit(editor) + + def _on_last_focused_editor_changed(self, editor: CDockWidget | None): + if editor is None: + self.save_enabled.emit(False) + return + + widget = cast(MonacoWidget, editor.widget()) + if widget.modified: + logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}") + self.save_enabled.emit(widget.modified) + + def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool): + """Update the tab title to show modification status with a dot indicator.""" + current_title = dock.windowTitle() + + # Remove existing modification indicator (dot and space) + if current_title.startswith("• "): + base_title = current_title[2:] # Remove "• " + else: + base_title = current_title + + # Add or remove the modification indicator + if modified: + new_title = f"• {base_title}" + else: + new_title = base_title + + dock.setWindowTitle(new_title) + + def _on_signature_change(self, signature: dict): + signatures = signature.get("signatures", []) + if not signatures: + self.signature_help.emit("") + return + + active_sig = signatures[signature.get("activeSignature", 0)] + active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param + + # Get signature label and documentation + label = active_sig.get("label", "") + doc_obj = active_sig.get("documentation", {}) + documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj) + + # Format the markdown output + markdown = f"```python\n{label}\n```\n\n{documentation}" + self.signature_help.emit(markdown) + + def _on_focus_event(self, old_widget, new_widget) -> None: + # Track focus events for the dock widget + widget = new_widget.widget() + if isinstance(widget, MonacoWidget): + self.last_focused_editor = new_widget + + def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget): + # Cast widget to MonacoWidget since we know that's what it is + monaco_widget = cast(MonacoWidget, widget) + + # Check if we have unsaved changes + if monaco_widget.modified: + # Prompt the user to save changes + response = QMessageBox.question( + self, + "Unsaved Changes", + "You have unsaved changes. Do you want to save them?", + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.Cancel, + ) + if response == QMessageBox.StandardButton.Yes: + self.save_file(monaco_widget) + elif response == QMessageBox.StandardButton.Cancel: + return + + # Count all editor docks managed by this dock manager + total = len(self.dock_manager.dockWidgets()) + if total <= 1: + # Do not remove the last dock; just wipe its editor content + # Temporarily disable read-only mode if the editor is read-only + # so we can clear the content for reuse + monaco_widget.set_readonly(False) + monaco_widget.set_text("") + dock.setWindowTitle("Untitled") + dock.setTabToolTip("Untitled") + return + + # Otherwise, proceed to close and delete the dock + monaco_widget.close() + dock.closeDockWidget() + dock.deleteDockWidget() + if self.last_focused_editor is dock: + self.last_focused_editor = None + # After topology changes, make sure single-tab areas get a plus button + QTimer.singleShot(0, self._scan_and_fix_areas) + + def _ensure_area_plus(self, area): + if area is None: + return + # Only add once per area + if getattr(area, "_monaco_plus_btn", None) is not None: + return + # If the area has exactly one tab, inject a + button next to the tab bar + try: + tabbar = area.titleBar().tabBar() + count = tabbar.count() if hasattr(tabbar, "count") else 1 + except Exception: + count = 1 + if count >= 1: + plus_btn = QToolButton(area) + plus_btn.setText("+") + plus_btn.setToolTip("New Monaco Editor") + plus_btn.setAutoRaise(True) + tb = area.titleBar() + idx = tb.indexOf(tb.tabBar()) + tb.insertWidget(idx + 1, plus_btn) + plus_btn.clicked.connect(lambda: self.add_editor(area)) + # pylint: disable=protected-access + area._monaco_plus_btn = plus_btn + + def _scan_and_fix_areas(self): + # Find all dock areas under this manager and ensure each single-tab area has a plus button + areas = self.dock_manager.findChildren(QtAds.CDockAreaWidget) + for a in areas: + self._ensure_area_plus(a) + + def eventFilter(self, obj, event): + # Track dock manager events + if obj is self.dock_manager and event.type() in ( + QEvent.Type.ChildAdded, + QEvent.Type.ChildRemoved, + QEvent.Type.LayoutRequest, + ): + QTimer.singleShot(0, self._scan_and_fix_areas) + + return super().eventFilter(obj, event) + + def add_editor( + self, area: Any | None = None, title: str | None = None, tooltip: str | None = None + ): # Any as qt ads does not return a proper type + """ + Adds a new Monaco editor dock widget to the dock manager. + """ + new_dock = self._create_editor() + if title is not None: + new_dock.setWindowTitle(title) + if tooltip is not None: + new_dock.setTabToolTip(tooltip) + if area is None: + area_obj = self.dock_manager.addDockWidgetTab(QtAds.TopDockWidgetArea, new_dock) + self._ensure_area_plus(area_obj) + else: + # If an area is provided, add the dock to that area + self.dock_manager.addDockWidgetTabToArea(new_dock, area) + self._ensure_area_plus(area) + + QTimer.singleShot(0, self._scan_and_fix_areas) + return new_dock + + def open_file(self, file_name: str): + """ + Open a file in the specified area. If the file is already open, activate it. + """ + open_files = self._get_open_files() + if file_name in open_files: + dock = self._get_editor_dock(file_name) + if dock is not None: + dock.setAsCurrentTab() + return + + file = os.path.basename(file_name) + # If the current editor is empty, we reuse it + + # For now, the dock manager is only for the editor docks. We can therefore safely assume + # that all docks are editor docks. + dock_area = self.dock_manager.dockArea(0) + + editor_dock = dock_area.currentDockWidget() + editor_widget = editor_dock.widget() if editor_dock else None + if editor_widget: + editor_widget = cast(MonacoWidget, editor_dock.widget()) + if editor_widget.current_file is None and editor_widget.get_text() == "": + editor_dock.setWindowTitle(file) + editor_dock.setTabToolTip(file_name) + editor_widget.open_file(file_name) + return + + # File is not open, create a new editor + editor_dock = self.add_editor(title=file, tooltip=file_name) + widget = cast(MonacoWidget, editor_dock.widget()) + widget.open_file(file_name) + + def save_file( + self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True + ) -> None: + """ + Save the currently focused file. + + Args: + widget (MonacoWidget | None): The widget to save. If None, the last focused editor will be used. + force_save_as (bool): If True, the "Save As" dialog will be shown even if the file is already saved. + """ + if widget is None: + widget = self.last_focused_editor.widget() if self.last_focused_editor else None + if not widget: + return + if widget.current_file and not force_save_as: + if format_on_save and pathlib.Path(widget.current_file).suffix == ".py": + widget.format() + with open(widget.current_file, "w", encoding="utf-8") as f: + f.write(widget.get_text()) + # pylint: disable=protected-access + widget._original_content = widget.get_text() + widget.save_enabled.emit(False) + return + + # Save as option + save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)") + + if save_file: + # check if we have suffix specified + file = pathlib.Path(save_file[0]) + if file.suffix == "": + file = file.with_suffix(".py") + if format_on_save and file.suffix == ".py": + widget.format() + + text = widget.get_text() + with open(file, "w", encoding="utf-8") as f: + f.write(text) + widget._original_content = text + + # Update the current_file before emitting save_enabled to ensure proper tracking + widget._current_file = str(file) + widget.save_enabled.emit(False) + + # Find the dock widget containing this monaco widget and update title + for dock in self.dock_manager.dockWidgets(): + if dock.widget() == widget: + dock.setWindowTitle(file.name) + dock.setTabToolTip(str(file)) + break + + print(f"Save file called, last focused editor: {self.last_focused_editor}") + + def set_vim_mode(self, enabled: bool): + """ + Set Vim mode for all editor widgets. + + Args: + enabled (bool): Whether to enable or disable Vim mode. + """ + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + editor_widget.set_vim_mode_enabled(enabled) + + def _get_open_files(self) -> list[str]: + open_files = [] + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + if editor_widget.current_file is not None: + open_files.append(editor_widget.current_file) + return open_files + + def _get_editor_dock(self, file_name: str) -> CDockWidget | None: + for widget in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, widget.widget()) + if editor_widget.current_file == file_name: + return widget + return None + + def set_file_readonly(self, file_name: str, read_only: bool = True) -> bool: + """ + Set a specific file's editor to read-only mode. + + Args: + file_name (str): The file path to set read-only + read_only (bool): Whether to set read-only mode (default: True) + + Returns: + bool: True if the file was found and read-only was set, False otherwise + """ + editor_dock = self._get_editor_dock(file_name) + if editor_dock: + editor_widget = cast(MonacoWidget, editor_dock.widget()) + editor_widget.set_readonly(read_only) + return True + return False + + def set_file_icon(self, file_name: str, icon) -> bool: + """ + Set an icon for a specific file's tab. + + Args: + file_name (str): The file path to set icon for + icon: The QIcon to set on the tab + + Returns: + bool: True if the file was found and icon was set, False otherwise + """ + editor_dock = self._get_editor_dock(file_name) + if editor_dock: + editor_dock.setIcon(icon) + return True + return False + + +if __name__ == "__main__": + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + _dock = MonacoDock() + _dock.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index eb05cec7..1a28eec0 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -1,11 +1,19 @@ +import os +import traceback from typing import Literal +import black +import isort import qtmonaco +from bec_lib.logger import bec_logger from qtpy.QtCore import Signal -from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget +from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_theme_name +from bec_widgets.utils.error_popups import SafeSlot + +logger = bec_logger.logger class MonacoWidget(BECWidget, QWidget): @@ -14,6 +22,7 @@ class MonacoWidget(BECWidget, QWidget): """ text_changed = Signal(str) + save_enabled = Signal(bool) PLUGIN = True ICON_NAME = "code" USER_ACCESS = [ @@ -21,6 +30,7 @@ class MonacoWidget(BECWidget, QWidget): "get_text", "insert_text", "delete_line", + "open_file", "set_language", "get_language", "set_theme", @@ -47,7 +57,19 @@ class MonacoWidget(BECWidget, QWidget): layout.addWidget(self.editor) self.setLayout(layout) self.editor.text_changed.connect(self.text_changed.emit) + self.editor.text_changed.connect(self._check_save_status) self.editor.initialized.connect(self.apply_theme) + self.editor.initialized.connect(self._setup_context_menu) + self.editor.context_menu_action_triggered.connect(self._handle_context_menu_action) + self._current_file = None + self._original_content = "" + + @property + def current_file(self): + """ + Get the current file being edited. + """ + return self._current_file def apply_theme(self, theme: str | None = None) -> None: """ @@ -61,14 +83,17 @@ class MonacoWidget(BECWidget, QWidget): editor_theme = "vs" if theme == "light" else "vs-dark" self.set_theme(editor_theme) - def set_text(self, text: str) -> None: + def set_text(self, text: str, file_name: str | None = None) -> None: """ Set the text in the Monaco editor. Args: text (str): The text to set in the editor. + file_name (str): Set the file name """ - self.editor.set_text(text) + self._current_file = file_name + self._original_content = text + self.editor.set_text(text, uri=file_name) def get_text(self) -> str: """ @@ -76,6 +101,32 @@ class MonacoWidget(BECWidget, QWidget): """ return self.editor.get_text() + def format(self) -> None: + """ + Format the current text in the Monaco editor. + """ + if not self.editor: + return + try: + content = self.get_text() + try: + formatted_content = black.format_str(content, mode=black.Mode(line_length=100)) + except Exception: # black.NothingChanged or other formatting exceptions + formatted_content = content + + config = isort.Config( + profile="black", + line_length=100, + multi_line_output=3, + include_trailing_comma=False, + known_first_party=["bec_widgets"], + ) + formatted_content = isort.code(formatted_content, config=config) + self.set_text(formatted_content, file_name=self.current_file) + except Exception: + content = traceback.format_exc() + logger.info(content) + def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None: """ Insert text at the current cursor position or at a specified line and column. @@ -96,6 +147,32 @@ class MonacoWidget(BECWidget, QWidget): """ self.editor.delete_line(line) + def open_file(self, file_name: str) -> None: + """ + Open a file in the editor. + + Args: + file_name (str): The path + file name of the file that needs to be displayed. + """ + + if not os.path.exists(file_name): + raise FileNotFoundError(f"The specified file does not exist: {file_name}") + + with open(file_name, "r", encoding="utf-8") as file: + content = file.read() + self.set_text(content, file_name=file_name) + + @property + def modified(self) -> bool: + """ + Check if the editor content has been modified. + """ + return self._original_content != self.get_text() + + @SafeSlot(str) + def _check_save_status(self, _text: str) -> None: + self.save_enabled.emit(self.modified) + def set_cursor( self, line: int, @@ -213,6 +290,36 @@ class MonacoWidget(BECWidget, QWidget): """ return self.editor.get_lsp_header() + def _setup_context_menu(self): + """Setup custom context menu actions for the Monaco editor.""" + # Add the "Insert Scan" action to the context menu + self.editor.add_action("insert_scan", "Insert Scan", "python") + # Add the "Format Code" action to the context menu + self.editor.add_action("format_code", "Format Code", "python") + + def _handle_context_menu_action(self, action_id: str): + """Handle context menu action triggers.""" + if action_id == "insert_scan": + self._show_scan_control_dialog() + elif action_id == "format_code": + self._format_code() + + def _show_scan_control_dialog(self): + """Show the scan control dialog and insert the generated scan code.""" + # Import here to avoid circular imports + from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog + + dialog = ScanControlDialog(self, client=self.client) + if dialog.exec_() == QDialog.DialogCode.Accepted: + scan_code = dialog.get_scan_code() + if scan_code: + # Insert the scan code at the current cursor position + self.insert_text(scan_code) + + def _format_code(self): + """Format the current code in the editor.""" + self.format() + if __name__ == "__main__": # pragma: no cover qapp = QApplication([]) @@ -234,7 +341,7 @@ if TYPE_CHECKING: 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 00000000..2cbb7121 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/scan_control_dialog.py @@ -0,0 +1,145 @@ +""" +Scan Control Dialog for Monaco Editor + +This module provides a dialog wrapper around the ScanControl widget, +allowing users to configure and generate scan code that can be inserted +into the Monaco editor. +""" + +from bec_lib.device import Device +from bec_lib.logger import bec_logger +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QPushButton, QVBoxLayout + +from bec_widgets.widgets.control.scan_control import ScanControl + +logger = bec_logger.logger + + +class ScanControlDialog(QDialog): + """ + Dialog window containing the ScanControl widget for generating scan code. + + This dialog allows users to configure scan parameters and generates + Python code that can be inserted into the Monaco editor. + """ + + def __init__(self, parent=None, client=None): + super().__init__(parent) + self.setWindowTitle("Insert Scan") + + # Store the client for passing to ScanControl + self.client = client + self._scan_code = "" + + self._setup_ui() + + def sizeHint(self) -> QSize: + return QSize(600, 800) + + def _setup_ui(self): + """Setup the dialog UI with ScanControl widget and buttons.""" + layout = QVBoxLayout(self) + + # Create the scan control widget + self.scan_control = ScanControl(parent=self, client=self.client) + self.scan_control.show_scan_control_buttons(False) + layout.addWidget(self.scan_control) + + # Create dialog buttons + button_box = QDialogButtonBox(Qt.Orientation.Horizontal, self) + + # Create custom buttons with appropriate text + insert_button = QPushButton("Insert") + cancel_button = QPushButton("Cancel") + + button_box.addButton(insert_button, QDialogButtonBox.ButtonRole.AcceptRole) + button_box.addButton(cancel_button, QDialogButtonBox.ButtonRole.RejectRole) + + layout.addWidget(button_box) + + # Connect button signals + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + def _generate_scan_code(self): + """Generate Python code for the configured scan.""" + try: + # Get scan parameters from the scan control widget + args, kwargs = self.scan_control.get_scan_parameters() + scan_name = self.scan_control.current_scan + + if not scan_name: + self._scan_code = "" + return + + # Process arguments and add device prefix where needed + processed_args = self._process_arguments_for_code_generation(args) + processed_kwargs = self._process_kwargs_for_code_generation(kwargs) + + # Generate the Python code string + code_parts = [] + + # Process arguments and keyword arguments + all_args = [] + + # Add positional arguments + if processed_args: + all_args.extend(processed_args) + + # Add keyword arguments (excluding metadata) + if processed_kwargs: + kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items() if k != "metadata"] + all_args.extend(kwargs_strs) + + # Join all arguments and create the scan call + args_str = ", ".join(all_args) + if args_str: + code_parts.append(f"scans.{scan_name}({args_str})") + else: + code_parts.append(f"scans.{scan_name}()") + + self._scan_code = "\n".join(code_parts) + + except Exception as e: + logger.error(f"Error generating scan code: {e}") + self._scan_code = f"# Error generating scan code: {e}\n" + + def _process_arguments_for_code_generation(self, args): + """Process arguments to add device prefixes and proper formatting.""" + return [self._format_value_for_code(arg) for arg in args] + + def _process_kwargs_for_code_generation(self, kwargs): + """Process keyword arguments to add device prefixes and proper formatting.""" + return {key: self._format_value_for_code(value) for key, value in kwargs.items()} + + def _format_value_for_code(self, value): + """Format a single value for code generation.""" + if isinstance(value, Device): + return f"dev.{value.name}" + return repr(value) + + def get_scan_code(self) -> str: + """ + Get the generated scan code. + + Returns: + str: The Python code for the configured scan. + """ + return self._scan_code + + def accept(self): + """Override accept to generate code before closing.""" + self._generate_scan_code() + super().accept() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = ScanControlDialog() + dialog.show() + sys.exit(app.exec_()) diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index 38a5b274..ba17e36b 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -1,13 +1,18 @@ import datetime import importlib +import importlib.metadata import os +import re +from bec_qthemes import material_icon +from qtpy.QtCore import Signal from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection from bec_widgets.widgets.containers.explorer.explorer import Explorer +from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget @@ -17,16 +22,19 @@ class IDEExplorer(BECWidget, QWidget): PLUGIN = True RPC = False + file_open_requested = Signal(str, str) + file_preview_requested = Signal(str, str) + def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) - self._sections = set() + self._sections = [] # Use list to maintain order instead of set self.main_explorer = Explorer(parent=self) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.main_explorer) self.setLayout(layout) - self.sections = ["scripts"] + self.sections = ["scripts", "macros"] @SafeProperty(list) def sections(self): @@ -35,10 +43,16 @@ class IDEExplorer(BECWidget, QWidget): @sections.setter def sections(self, value): existing_sections = set(self._sections) - self._sections = set(value) - self._update_section_visibility(self._sections - existing_sections) + new_sections = set(value) + # Find sections to add, maintaining the order from the input value list + sections_to_add = [ + section for section in value if section in (new_sections - existing_sections) + ] + self._sections = list(value) # Store as ordered list + self._update_section_visibility(sections_to_add) def _update_section_visibility(self, sections): + # sections is now an ordered list, not a set for section in sections: self._add_section(section) @@ -46,15 +60,18 @@ class IDEExplorer(BECWidget, QWidget): match section_name.lower(): case "scripts": self.add_script_section() + case "macros": + self.add_macro_section() case _: pass def add_script_section(self): section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0) - section.expanded = False script_explorer = Explorer(parent=self) script_widget = ScriptTreeWidget(parent=self) + script_widget.file_open_requested.connect(self._emit_file_open_scripts_local) + script_widget.file_selected.connect(self._emit_file_preview_scripts_local) local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) local_scripts_section.header_add_button.clicked.connect(self._add_local_script) local_scripts_section.set_widget(script_widget) @@ -77,15 +94,85 @@ class IDEExplorer(BECWidget, QWidget): if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir): return - shared_script_section = CollapsibleSection(title="Shared", parent=self) + shared_script_section = CollapsibleSection(title="Shared (Read-only)", parent=self) + shared_script_section.setToolTip("Shared scripts (read-only)") shared_script_widget = ScriptTreeWidget(parent=self) shared_script_section.set_widget(shared_script_widget) shared_script_widget.set_directory(plugin_scripts_dir) script_explorer.add_section(shared_script_section) + shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared) + shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared) # macros_section = CollapsibleSection("MACROS", indentation=0) # macros_section.set_widget(QLabel("Macros will be implemented later")) # self.main_explorer.add_section(macros_section) + def add_macro_section(self): + section = CollapsibleSection( + parent=self, title="MACROS", indentation=0, show_add_button=True + ) + section.header_add_button.setIcon(material_icon("refresh", size=(20, 20))) + section.header_add_button.setToolTip("Reload all macros") + section.header_add_button.clicked.connect(self._reload_macros) + + macro_explorer = Explorer(parent=self) + macro_widget = MacroTreeWidget(parent=self) + macro_widget.macro_open_requested.connect(self._emit_file_open_macros_local) + macro_widget.macro_selected.connect(self._emit_file_preview_macros_local) + local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) + local_macros_section.header_add_button.clicked.connect(self._add_local_macro) + local_macros_section.set_widget(macro_widget) + local_macro_dir = self.client._service_config.model.user_macros.base_path + if not os.path.exists(local_macro_dir): + os.makedirs(local_macro_dir) + macro_widget.set_directory(local_macro_dir) + macro_explorer.add_section(local_macros_section) + + section.set_widget(macro_explorer) + self.main_explorer.add_section(section) + + plugin_macros_dir = None + plugins = importlib.metadata.entry_points(group="bec") + for plugin in plugins: + if plugin.name == "plugin_bec": + plugin = plugin.load() + plugin_macros_dir = os.path.join(plugin.__path__[0], "macros") + break + + if not plugin_macros_dir or not os.path.exists(plugin_macros_dir): + return + shared_macro_section = CollapsibleSection(title="Shared (Read-only)", parent=self) + shared_macro_section.setToolTip("Shared macros (read-only)") + shared_macro_widget = MacroTreeWidget(parent=self) + shared_macro_section.set_widget(shared_macro_widget) + shared_macro_widget.set_directory(plugin_macros_dir) + macro_explorer.add_section(shared_macro_section) + shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared) + shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared) + + def _emit_file_open_scripts_local(self, file_name: str): + self.file_open_requested.emit(file_name, "scripts/local") + + def _emit_file_preview_scripts_local(self, file_name: str): + self.file_preview_requested.emit(file_name, "scripts/local") + + def _emit_file_open_scripts_shared(self, file_name: str): + self.file_open_requested.emit(file_name, "scripts/shared") + + def _emit_file_preview_scripts_shared(self, file_name: str): + self.file_preview_requested.emit(file_name, "scripts/shared") + + def _emit_file_open_macros_local(self, function_name: str, file_path: str): + self.file_open_requested.emit(file_path, "macros/local") + + def _emit_file_preview_macros_local(self, function_name: str, file_path: str): + self.file_preview_requested.emit(file_path, "macros/local") + + def _emit_file_open_macros_shared(self, function_name: str, file_path: str): + self.file_open_requested.emit(file_path, "macros/shared") + + def _emit_file_preview_macros_shared(self, function_name: str, file_path: str): + self.file_preview_requested.emit(file_path, "macros/shared") + def _add_local_script(self): """Show a dialog to enter the name of a new script and create it.""" @@ -136,6 +223,98 @@ class IDEExplorer(BECWidget, QWidget): # Show error if file creation failed QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}") + def _add_local_macro(self): + """Show a dialog to enter the name of a new macro function and create it.""" + + target_section = self.main_explorer.get_section("MACROS") + macro_dir_section = target_section.content_widget.get_section("Local") + + local_macro_dir = macro_dir_section.content_widget.directory + + # Prompt user for function name + function_name, ok = QInputDialog.getText(self, "New Macro", f"Enter macro function name:") + + if not ok or not function_name: + return # User cancelled or didn't enter a name + + # Sanitize function name + function_name = re.sub(r"[^a-zA-Z0-9_]", "_", function_name) + if not function_name or function_name[0].isdigit(): + QMessageBox.warning( + self, "Invalid Name", "Function name must be a valid Python identifier." + ) + return + + # Create filename based on function name + filename = f"{function_name}.py" + file_path = os.path.join(local_macro_dir, filename) + + # Check if file already exists + if os.path.exists(file_path): + response = QMessageBox.question( + self, + "File exists", + f"The file '{filename}' already exists. Do you want to overwrite it?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if response != QMessageBox.StandardButton.Yes: + return # User chose not to overwrite + + try: + # Create the file with a macro function template + with open(file_path, "w", encoding="utf-8") as f: + f.write( + f'''""" +{function_name} macro - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +""" + + +def {function_name}(): + """ + Description of what this macro does. + + Add your macro implementation here. + """ + print(f"Executing macro: {function_name}") + # TODO: Add your macro code here + pass +''' + ) + + # Refresh the macro tree to show the new function + macro_dir_section.content_widget.refresh() + + except Exception as e: + # Show error if file creation failed + QMessageBox.critical(self, "Error", f"Failed to create macro: {str(e)}") + + def _reload_macros(self): + """Reload all macros using the BEC client.""" + try: + if hasattr(self.client, "macros"): + self.client.macros.load_all_user_macros() + + # Refresh the macro tree widgets to show updated functions + target_section = self.main_explorer.get_section("MACROS") + if target_section and hasattr(target_section, "content_widget"): + local_section = target_section.content_widget.get_section("Local") + if local_section and hasattr(local_section, "content_widget"): + local_section.content_widget.refresh() + + shared_section = target_section.content_widget.get_section("Shared") + if shared_section and hasattr(shared_section, "content_widget"): + shared_section.content_widget.refresh() + + QMessageBox.information( + self, "Reload Macros", "Macros have been reloaded successfully." + ) + else: + QMessageBox.warning(self, "Reload Macros", "Macros functionality is not available.") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}") + if __name__ == "__main__": from qtpy.QtWidgets import QApplication diff --git a/tests/unit_tests/test_tree_widget.py b/tests/unit_tests/test_tree_widget.py new file mode 100644 index 00000000..7a470cce --- /dev/null +++ b/tests/unit_tests/test_tree_widget.py @@ -0,0 +1,22 @@ +import pytest +from qtpy.QtWidgets import QTreeView, QWidget + +from bec_widgets.utils.colors import apply_theme + + +class DummyTree(QWidget): + def __init__(self): + super().__init__() + tree = QTreeView(self) + + +@pytest.fixture +def tree_widget(qtbot): + tree = DummyTree() + qtbot.addWidget(tree) + qtbot.waitExposed(tree) + yield tree + + +def test_tree_widget_init(tree_widget): + assert isinstance(tree_widget, QWidget)