diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 475ff5cb..5c7fb10e 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -41,6 +41,7 @@ _Widgets = { "Image": "Image", "LogPanel": "LogPanel", "Minesweeper": "Minesweeper", + "MonacoWidget": "MonacoWidget", "MotorMap": "MotorMap", "MultiWaveform": "MultiWaveform", "PositionIndicator": "PositionIndicator", @@ -2418,6 +2419,98 @@ class LogPanel(RPCBase): class Minesweeper(RPCBase): ... +class MonacoWidget(RPCBase): + """A simple Monaco editor widget""" + + @rpc_call + def set_text(self, text: str) -> None: + """ + Set the text in the Monaco editor. + + Args: + text (str): The text to set in the editor. + """ + + @rpc_call + def get_text(self) -> str: + """ + Get the current text from the Monaco editor. + """ + + @rpc_call + def set_language(self, language: str) -> None: + """ + Set the programming language for syntax highlighting in the Monaco editor. + + Args: + language (str): The programming language to set (e.g., "python", "javascript"). + """ + + @rpc_call + def get_language(self) -> str: + """ + Get the current programming language set in the Monaco editor. + """ + + @rpc_call + def set_theme(self, theme: str) -> None: + """ + Set the theme for the Monaco editor. + + Args: + theme (str): The theme to set (e.g., "vs-dark", "light"). + """ + + @rpc_call + def get_theme(self) -> str: + """ + Get the current theme of the Monaco editor. + """ + + @rpc_call + def set_readonly(self, read_only: bool) -> None: + """ + Set the Monaco editor to read-only mode. + + Args: + read_only (bool): If True, the editor will be read-only. + """ + + @rpc_call + def set_cursor( + self, + line: int, + column: int = 1, + move_to_position: Literal[None, "center", "top", "position"] = None, + ) -> None: + """ + Set the cursor position in the Monaco editor. + + Args: + line (int): Line number (1-based). + column (int): Column number (1-based), defaults to 1. + move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to. + """ + + @rpc_call + def current_cursor(self) -> dict[str, int]: + """ + Get the current cursor position in the Monaco editor. + + Returns: + dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position. + """ + + @rpc_call + def set_minimap_enabled(self, enabled: bool) -> None: + """ + Enable or disable the minimap in the Monaco editor. + + Args: + enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled. + """ + + class MotorMap(RPCBase): """Motor map widget for plotting motor positions in 2D including a trace of the last points.""" diff --git a/bec_widgets/cli/server.py b/bec_widgets/cli/server.py index caa581d6..ea45c61e 100644 --- a/bec_widgets/cli/server.py +++ b/bec_widgets/cli/server.py @@ -9,6 +9,7 @@ from contextlib import redirect_stderr, redirect_stdout from bec_lib.logger import bec_logger from bec_lib.service_config import ServiceConfig +from qtmonaco.pylsp_provider import pylsp_server from qtpy.QtCore import QSize, Qt from qtpy.QtGui import QIcon from qtpy.QtWidgets import QApplication @@ -142,6 +143,8 @@ class GUIServer: """ Shutdown the GUI server. """ + if pylsp_server.is_running(): + pylsp_server.stop() if self.dispatcher: self.dispatcher.stop_cli_server() self.dispatcher.disconnect_all() diff --git a/bec_widgets/widgets/editors/monaco/__init__.py b/bec_widgets/widgets/editors/monaco/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py new file mode 100644 index 00000000..dc665aea --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -0,0 +1,188 @@ +from typing import Literal + +import qtmonaco +from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_theme_name + + +class MonacoWidget(BECWidget, QWidget): + """ + A simple Monaco editor widget + """ + + PLUGIN = True + ICON_NAME = "code" + USER_ACCESS = [ + "set_text", + "get_text", + "set_language", + "get_language", + "set_theme", + "get_theme", + "set_readonly", + "set_cursor", + "current_cursor", + "set_minimap_enabled", + ] + + def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs): + super().__init__( + parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs + ) + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + self.editor = qtmonaco.Monaco(self) + layout.addWidget(self.editor) + self.setLayout(layout) + self.editor.initialized.connect(self.apply_theme) + + def apply_theme(self, theme: str | None = None) -> None: + """ + Apply the current theme to the Monaco editor. + + Args: + theme (str, optional): The theme to apply. If None, the current theme will be used. + """ + if theme is None: + theme = get_theme_name() + editor_theme = "vs" if theme == "light" else "vs-dark" + self.set_theme(editor_theme) + + def set_text(self, text: str) -> None: + """ + Set the text in the Monaco editor. + + Args: + text (str): The text to set in the editor. + """ + self.editor.set_text(text) + + def get_text(self) -> str: + """ + Get the current text from the Monaco editor. + """ + return self.editor.get_text() + + def set_cursor( + self, + line: int, + column: int = 1, + move_to_position: Literal[None, "center", "top", "position"] = None, + ) -> None: + """ + Set the cursor position in the Monaco editor. + + Args: + line (int): Line number (1-based). + column (int): Column number (1-based), defaults to 1. + move_to_position (Literal[None, "center", "top", "position"], optional): Position to move the cursor to. + """ + self.editor.set_cursor(line, column, move_to_position) + + def current_cursor(self) -> dict[str, int]: + """ + Get the current cursor position in the Monaco editor. + + Returns: + dict[str, int]: A dictionary with keys 'line' and 'column' representing the cursor position. + """ + return self.editor.current_cursor + + def set_language(self, language: str) -> None: + """ + Set the programming language for syntax highlighting in the Monaco editor. + + Args: + language (str): The programming language to set (e.g., "python", "javascript"). + """ + self.editor.set_language(language) + + def get_language(self) -> str: + """ + Get the current programming language set in the Monaco editor. + """ + return self.editor.get_language() + + def set_readonly(self, read_only: bool) -> None: + """ + Set the Monaco editor to read-only mode. + + Args: + read_only (bool): If True, the editor will be read-only. + """ + self.editor.set_readonly(read_only) + + def set_theme(self, theme: str) -> None: + """ + Set the theme for the Monaco editor. + + Args: + theme (str): The theme to set (e.g., "vs-dark", "light"). + """ + self.editor.set_theme(theme) + + def get_theme(self) -> str: + """ + Get the current theme of the Monaco editor. + """ + return self.editor.get_theme() + + def set_minimap_enabled(self, enabled: bool) -> None: + """ + Enable or disable the minimap in the Monaco editor. + + Args: + enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled. + """ + self.editor.set_minimap_enabled(enabled) + + def set_highlighted_lines(self, start_line: int, end_line: int) -> None: + """ + Highlight a range of lines in the Monaco editor. + + Args: + start_line (int): The starting line number (1-based). + end_line (int): The ending line number (1-based). + """ + self.editor.set_highlighted_lines(start_line, end_line) + + def clear_highlighted_lines(self) -> None: + """ + Clear any highlighted lines in the Monaco editor. + """ + self.editor.clear_highlighted_lines() + + +if __name__ == "__main__": # pragma: no cover + qapp = QApplication([]) + widget = MonacoWidget() + # set the default size + widget.resize(800, 600) + widget.set_language("python") + widget.set_theme("vs-dark") + widget.editor.set_minimap_enabled(False) + widget.set_text( + """ +import numpy as np +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bec_lib.devicemanager import DeviceContainer + from bec_lib.scans import Scans + dev: DeviceContainer + scans: Scans + +####################################### +########## User Script ##################### +####################################### + +# This is a comment +def hello_world(): + print("Hello, world!") + """ + ) + widget.set_highlighted_lines(1, 3) + widget.show() + qapp.exec_() diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.pyproject b/bec_widgets/widgets/editors/monaco/monaco_widget.pyproject new file mode 100644 index 00000000..e77c30e5 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.pyproject @@ -0,0 +1 @@ +{'files': ['monaco_widget.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget_plugin.py b/bec_widgets/widgets/editors/monaco/monaco_widget_plugin.py new file mode 100644 index 00000000..4bd1f1d6 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/monaco_widget_plugin.py @@ -0,0 +1,54 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + +DOM_XML = """ + + + + +""" + + +class MonacoWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = MonacoWidget(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "BEC Developer" + + def icon(self): + return designer_material_icon(MonacoWidget.ICON_NAME) + + def includeFile(self): + return "monaco_widget" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "MonacoWidget" + + def toolTip(self): + return "" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/editors/monaco/register_monaco_widget.py b/bec_widgets/widgets/editors/monaco/register_monaco_widget.py new file mode 100644 index 00000000..0baa1a9d --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/register_monaco_widget.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.editors.monaco.monaco_widget_plugin import MonacoWidgetPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(MonacoWidgetPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/pyproject.toml b/pyproject.toml index dd3922c8..ed2caca5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "PySide6~=6.8.2", "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", + "qtmonaco>=0.2.3", ] diff --git a/tests/unit_tests/test_monaco_editor.py b/tests/unit_tests/test_monaco_editor.py new file mode 100644 index 00000000..149f4d75 --- /dev/null +++ b/tests/unit_tests/test_monaco_editor.py @@ -0,0 +1,39 @@ +import pytest + +from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget + + +@pytest.fixture +def monaco_widget(qtbot): + widget = MonacoWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_monaco_widget_set_text(monaco_widget: MonacoWidget, qtbot): + """ + Test that the MonacoWidget can set text correctly. + """ + test_text = "Hello, Monaco!" + monaco_widget.set_text(test_text) + qtbot.waitUntil(lambda: monaco_widget.get_text() == test_text, timeout=1000) + assert monaco_widget.get_text() == test_text + + +def test_monaco_widget_readonly(monaco_widget: MonacoWidget, qtbot): + """ + Test that the MonacoWidget can be set to read-only mode. + """ + monaco_widget.set_text("Initial text") + qtbot.waitUntil(lambda: monaco_widget.get_text() == "Initial text", timeout=1000) + monaco_widget.set_readonly(True) + + with pytest.raises(ValueError): + monaco_widget.set_text("This should not change") + + monaco_widget.set_readonly(False) # Set back to editable + qtbot.wait(100) + monaco_widget.set_text("Attempting to change text") + qtbot.waitUntil(lambda: monaco_widget.get_text() == "Attempting to change text", timeout=1000) + assert monaco_widget.get_text() == "Attempting to change text"