1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-12-30 02:31:20 +01:00

feat(monaco): various minor improvements for the developer view

This commit is contained in:
2025-08-23 21:37:42 +02:00
committed by Klaus Wakonig
parent 9def1e2f71
commit 00481a0aed
2 changed files with 236 additions and 16 deletions

View File

@@ -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

View File

@@ -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,