From 00481a0aedc0bd48cd9c8afcb805497cc7fc2645 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sat, 23 Aug 2025 21:37:42 +0200 Subject: [PATCH] feat(monaco): various minor improvements for the developer view --- .../widgets/editors/monaco/monaco_tab.py | 205 ++++++++++++++++-- .../widgets/editors/monaco/monaco_widget.py | 47 +++- 2 files changed, 236 insertions(+), 16 deletions(-) diff --git a/bec_widgets/widgets/editors/monaco/monaco_tab.py b/bec_widgets/widgets/editors/monaco/monaco_tab.py index 7362b632..be320fcb 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_tab.py +++ b/bec_widgets/widgets/editors/monaco/monaco_tab.py @@ -1,7 +1,12 @@ +from __future__ import annotations + +import os +from typing import Any, cast + import PySide6QtAds as QtAds from PySide6QtAds import CDockWidget -from qtpy.QtCore import QEvent, QTimer -from qtpy.QtWidgets import QToolButton, QVBoxLayout, QWidget +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 @@ -13,23 +18,32 @@ class MonacoDock(BECWidget, QWidget): It is used to manage multiple Monaco editors in a dockable interface. """ - def __init__(self, parent=None, *args, **kwargs): - super().__init__(parent, *args, **kwargs) + 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.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"Editor {count + 1}") + dock = CDockWidget(f"Untitled_{count + 1}") dock.setWidget(widget) dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True) @@ -42,18 +56,83 @@ class MonacoDock(BECWidget, QWidget): 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()) + self.save_enabled.emit(widget.modified) + + 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): + # Check if we have unsaved changes + if 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(widget) + elif response == QMessageBox.StandardButton.Cancel: + return + # Count all editor docks managed by this dock manager - # TODO change this is wrong total = len(self.dock_manager.dockWidgets()) if total <= 1: # Do not remove the last dock; just wipe its editor content - widget.set_text("") + if hasattr(widget, "set_text"): + widget.set_text("") + dock.setWindowTitle("Untitled") + dock.setTabToolTip("Untitled") return + # Otherwise, proceed to close and delete the dock 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) @@ -87,22 +166,30 @@ class MonacoDock(BECWidget, QWidget): 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.ChildAdded, - QEvent.ChildRemoved, - QEvent.LayoutRequest, + 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=None): + 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 = self.dock_manager.addDockWidgetTab(QtAds.TopDockWidgetArea, new_dock) - self._ensure_area_plus(area) + 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) @@ -111,6 +198,96 @@ class MonacoDock(BECWidget, QWidget): 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) -> 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: + with open(widget.current_file, "w") as f: + f.write(widget.get_text()) + widget._original_content = widget.get_text() + widget.save_enabled.emit(False) + return + + # Save as option + file_dialog = QFileDialog(self, "Save File As") + file_dialog.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) + file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles) + file_dialog.setNameFilter("Text files (*.txt);;All files (*)") + file_dialog.setDefaultSuffix("txt") + + if file_dialog.exec_() == QFileDialog.Accepted: + selected_files = file_dialog.selectedFiles() + if selected_files: + with open(selected_files[0], "w") as f: + f.write(widget.get_text()) + widget._original_content = widget.get_text() + widget.save_enabled.emit(False) + + print(f"Save file called, last focused editor: {self.last_focused_editor}") + + def set_vim_mode(self, enabled: bool): + # Toggle Vim mode for all editor widgets + 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 + if __name__ == "__main__": import sys diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index eb05cec7..bdda57d3 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -1,3 +1,4 @@ +import os from typing import Literal import qtmonaco @@ -6,6 +7,7 @@ from qtpy.QtWidgets import QApplication, 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 class MonacoWidget(BECWidget, QWidget): @@ -14,6 +16,7 @@ class MonacoWidget(BECWidget, QWidget): """ text_changed = Signal(str) + save_enabled = Signal(bool) PLUGIN = True ICON_NAME = "code" USER_ACCESS = [ @@ -21,6 +24,7 @@ class MonacoWidget(BECWidget, QWidget): "get_text", "insert_text", "delete_line", + "open_file", "set_language", "get_language", "set_theme", @@ -47,7 +51,17 @@ 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._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 +75,16 @@ 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.editor.set_text(text, uri=file_name) + self._current_file = file_name def get_text(self) -> str: """ @@ -96,6 +112,33 @@ 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._original_content = content + 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,