1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-05-07 23:32:12 +02:00
Files
bec_widgets/bec_widgets/widgets/editors/monaco/monaco_dock.py
T

495 lines
19 KiB
Python

from __future__ import annotations
import os
import pathlib
from typing import Any, cast
from bec_lib.logger import bec_logger
from bec_lib.macro_update_handler import has_executable_code
from qtpy.QtCore import QEvent, QTimer, Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QWidget
from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.qt_ads import CDockAreaWidget, CDockWidget
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
logger = bec_logger.logger
class MonacoDock(DockAreaWidget):
"""
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
macro_file_updated = Signal(str) # Emitted when a macro file is saved
def __init__(self, parent=None, **kwargs):
super().__init__(
parent=parent,
variant="compact",
title="Monaco Editors",
default_add_direction="top",
**kwargs,
)
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
self.dock_manager.installEventFilter(self)
self._last_focused_editor: CDockWidget | None = None
self.focused_editor.connect(self._on_last_focused_editor_changed)
initial_editor = self.add_editor()
if isinstance(initial_editor, CDockWidget):
self.last_focused_editor = initial_editor
def _create_editor_widget(self) -> MonacoWidget:
"""Create a configured Monaco editor widget."""
init_lsp = len(self.dock_manager.dockWidgets()) == 0
widget = MonacoWidget(self, init_lsp=init_lsp)
widget.save_enabled.connect(self.save_enabled.emit)
widget.editor.signature_help_triggered.connect(self._on_signature_change)
return widget
@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
self.reset_widget(monaco_widget)
dock.setWindowTitle("Untitled")
dock.setTabToolTip("Untitled")
icon = self._resolve_dock_icon(monaco_widget, dock_icon=None, apply_widget_icon=True)
dock.setIcon(icon)
self.last_focused_editor = dock
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 reset_widget(self, widget: MonacoWidget):
"""
Reset the given Monaco editor widget to its initial state.
Args:
widget (MonacoWidget): The Monaco editor widget to reset.
"""
widget.set_readonly(False)
widget.set_text("", reset=True)
widget.metadata["scope"] = ""
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(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
) -> CDockWidget:
"""
Add a new Monaco editor dock to the specified area.
Args:
area(Any | None): The area to add the editor to. If None, adds to the main area.
title(str | None): The title of the editor tab. If None, a default title is used.
tooltip(str | None): The tooltip for the editor tab. If None, no tooltip is set.
Returns:
CDockWidget: The created dock widget containing the Monaco editor.
"""
widget = self._create_editor_widget()
existing_count = len(self.dock_manager.dockWidgets())
default_title = title or f"Untitled_{existing_count + 1}"
tab_target: CDockWidget | None = None
if isinstance(area, CDockAreaWidget):
tab_target = area.currentDockWidget()
if tab_target is None:
docks = area.dockWidgets()
tab_target = docks[0] if docks else None
dock = self.new(
widget,
closable=True,
floatable=False,
movable=True,
tab_with=tab_target,
return_dock=True,
on_close=self._on_editor_close_requested,
title_buttons={"float": False},
where="right",
)
dock.setWindowTitle(default_title)
if tooltip is not None:
dock.setTabToolTip(tooltip)
widget.save_enabled.connect(
lambda modified, target=dock: self._update_tab_title_for_modification(target, modified)
)
area_widget = dock.dockAreaWidget()
if area_widget is not None:
self._ensure_area_plus(area_widget)
QTimer.singleShot(0, self._scan_and_fix_areas)
self.last_focused_editor = dock
return dock
def open_file(self, file_name: str, scope: str = "") -> None:
"""
Open a file in the specified area. If the file is already open, activate it.
Args:
file_name (str): The path to the file to open.
scope (str): The scope to set for the editor metadata.
"""
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()
self.last_focused_editor = dock
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)
if not dock_area:
return
editor_dock = dock_area.currentDockWidget()
if not editor_dock:
return
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)
editor_widget.metadata["scope"] = scope
self.last_focused_editor = editor_dock
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)
widget.metadata["scope"] = scope
editor_dock.setAsCurrentTab()
self.last_focused_editor = editor_dock
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.
format_on_save (bool): If True, format the code before saving if it's a Python file.
"""
if widget is None:
widget = self.last_focused_editor.widget() if self.last_focused_editor else None
if not widget:
return
if "macros" in widget.metadata.get("scope", ""):
if not self._validate_macros(widget.get_text()):
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())
if "macros" in widget.metadata.get("scope", ""):
self._update_macros(widget)
# Emit signal to refresh macro tree widget
self.macro_file_updated.emit(widget.current_file)
# 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 not save_file or not save_file[0]:
return
# 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
if "macros" in widget.metadata.get("scope", ""):
self._update_macros(widget)
# Emit signal to refresh macro tree widget
self.macro_file_updated.emit(str(file))
logger.debug(f"Save file called, last focused editor: {self.last_focused_editor}")
def _validate_macros(self, source: str) -> bool:
# pylint: disable=protected-access
# Ensure the macro does not contain executable code before saving
exec_code, line_number = has_executable_code(source)
if exec_code:
if line_number is None:
msg = "The macro contains executable code. Please remove it before saving."
else:
msg = f"The macro contains executable code on line {line_number}. Please remove it before saving."
QMessageBox.warning(self, "Save Error", msg)
return False
return True
def _update_macros(self, widget: MonacoWidget):
# pylint: disable=protected-access
if not widget.current_file:
return
# Check which macros have changed and broadcast the change
macros = self.client.macros._update_handler.get_macros_from_file(widget.current_file)
existing_macros = self.client.macros._update_handler.get_existing_macros(
widget.current_file
)
removed_macros = set(existing_macros.keys()) - set(macros.keys())
added_macros = set(macros.keys()) - set(existing_macros.keys())
for name, info in macros.items():
if name in added_macros:
self.client.macros._update_handler.broadcast(
action="add", name=name, file_path=widget.current_file
)
if (
name in existing_macros
and info.get("source", "") != existing_macros[name]["source"]
):
self.client.macros._update_handler.broadcast(
action="reload", name=name, file_path=widget.current_file
)
for name in removed_macros:
self.client.macros._update_handler.broadcast(action="remove", name=name)
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())