diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 5e460de7..42092f25 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -40,6 +40,7 @@ _Widgets = { "Image": "Image", "LogPanel": "LogPanel", "Minesweeper": "Minesweeper", + "MonacoWidget": "MonacoWidget", "MotorMap": "MotorMap", "MultiWaveform": "MultiWaveform", "PositionIndicator": "PositionIndicator", @@ -1879,6 +1880,68 @@ 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 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 set_read_only(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. + """ + + class MotorMap(RPCBase): """Motor map widget for plotting motor positions in 2D including a trace of the last points.""" 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/core/__init__.py b/bec_widgets/widgets/editors/monaco/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/editors/monaco/core/bridge_base.py b/bec_widgets/widgets/editors/monaco/core/bridge_base.py new file mode 100644 index 00000000..04a61685 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/core/bridge_base.py @@ -0,0 +1,65 @@ +import json + +from qtpy.QtCore import QObject, Signal, Slot + + +class BaseBridge(QObject): + initialized = Signal() + sendDataChanged = Signal(str, str) + completion = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._initialized = False + self._buffer = [] + self.initialized.connect(self._process_startup_buffer) + + def _process_startup_buffer(self): + """ + Process the buffer of data that was sent before the bridge was initialized. + This is useful for sending initial data to the JavaScript side. + """ + for name, value in self._buffer: + self._send_to_js(name, value) + self._buffer.clear() + + # Update the local buffer by reading the current state + # This is mostly to ensure that we are in sync with the JS side + self._send_to_js("read", "") + + def _send_to_js(self, name, value): + if not self._initialized: + self._buffer.append((name, value)) + return + data = json.dumps(value) + self.sendDataChanged.emit(name, data) + + @Slot(str, str) + def receive_from_js(self, name, value): + data = json.loads(value) + + if name == "bridge_initialized": + self._initialized = data + self.initialized.emit() + return + if name == "setValue": + self.on_value_changed(data) + return + print(f"Received from JS: {name} = {data}") + self.setProperty(name, data) + + @property + def bridge_initialized(self): + return self._initialized + + @bridge_initialized.setter + def bridge_initialized(self, value): + if self._initialized != value: + self._initialized = value + self.initialized.emit() + + def on_value_changed(self, value): + """ + Placeholder method to handle value changes. + This can be overridden in subclasses to implement specific behavior. + """ diff --git a/bec_widgets/widgets/editors/monaco/core/editor_bridge.py b/bec_widgets/widgets/editors/monaco/core/editor_bridge.py new file mode 100644 index 00000000..9d503de5 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/core/editor_bridge.py @@ -0,0 +1,101 @@ +from typing import Literal + +from qtpy.QtCore import Signal + +from bec_widgets.widgets.editors.monaco.core.bridge_base import BaseBridge + + +class EditorBridge(BaseBridge): + valueChanged = Signal() + languageChanged = Signal() + themeChanged = Signal() + + def __init__(self, parent=None): + super().__init__(parent=parent) + self._value = "" + self._language = "" + self._theme = "" + self._readonly = False + + def on_value_changed(self, value): + """Handle value changes from the JavaScript side.""" + self.setValue(value) + + def getValue(self): + return self._value + + def setValue(self, value: str): + """ + Set the value in the editor. + + Args: + value (str): The new value to set in the editor. + """ + if self._value == value: + return + if self._readonly: + raise ValueError("Editor is in read-only mode, cannot set value.") + if not isinstance(value, str): + raise TypeError("Value must be a string.") + self._value = value + self.valueChanged.emit() + + def getLanguage(self): + return self._language + + def setLanguage(self, language): + self._language = language + self._send_to_js("language", language) + self.languageChanged.emit() + + def getTheme(self): + return self._theme + + def setTheme(self, theme): + self._theme = theme + self._send_to_js("theme", theme) + self.themeChanged.emit() + + def setReadOnly(self, read_only: bool): + """Set the editor to read-only mode.""" + self._send_to_js("readOnly", read_only) + self._readonly = read_only + + @property + def value(self): + return self._value + + @property + def language(self): + return self._language + + @property + def theme(self): + return self._theme + + def setHost(self, host: str): + """ + Set the host for the editor. + + Args: + host (str): The host URL for the editor. + """ + self._send_to_js("lsp_url", host) + + def setCursor( + self, + line: int, + column: int = 1, + move_to_position: Literal[None, "center", "top", "position"] = None, + ): + """ + Set the cursor position in the 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._send_to_js( + "setCursor", {"line": line, "column": column, "moveToPosition": move_to_position} + ) diff --git a/bec_widgets/widgets/editors/monaco/core/monaco.rcc b/bec_widgets/widgets/editors/monaco/core/monaco.rcc new file mode 100644 index 00000000..76abb95b Binary files /dev/null and b/bec_widgets/widgets/editors/monaco/core/monaco.rcc differ diff --git a/bec_widgets/widgets/editors/monaco/core/monaco_page.py b/bec_widgets/widgets/editors/monaco/core/monaco_page.py new file mode 100644 index 00000000..a87c9f6c --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/core/monaco_page.py @@ -0,0 +1,9 @@ +from bec_lib.logger import bec_logger +from qtpy.QtWebEngineCore import QWebEnginePage + +logger = bec_logger.logger + + +class MonacoPage(QWebEnginePage): + def javaScriptConsoleMessage(self, level, message, line, source): + logger.debug(f"[JS Console] {level.name} at line {line} in {source}: {message}") diff --git a/bec_widgets/widgets/editors/monaco/core/monaco_web_view.py b/bec_widgets/widgets/editors/monaco/core/monaco_web_view.py new file mode 100644 index 00000000..83cbf585 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/core/monaco_web_view.py @@ -0,0 +1,122 @@ +from typing import Literal + +from qtpy.QtCore import Signal +from qtpy.QtWebChannel import QWebChannel +from qtpy.QtWebEngineWidgets import QWebEngineView + +from bec_widgets.widgets.editors.monaco.core.editor_bridge import EditorBridge +from bec_widgets.widgets.editors.monaco.core.monaco_page import MonacoPage +from bec_widgets.widgets.editors.monaco.core.resource_loader import ( + get_monaco_base_url, + get_monaco_html, +) + + +def get_pylsp_host() -> str: + """ + Get the host address for the PyLSP server. + This function initializes the PyLSP server if it is not already running + and returns the host address in the format 'localhost:port'. + Returns: + str: The host address of the PyLSP server. + """ + # lazy import to only load when needed + from bec_widgets.widgets.editors.monaco.core.pylsp_provider import pylsp_server + + if not pylsp_server.is_running(): + pylsp_server.start() + + return f"localhost:{pylsp_server.port}" + + +class MonacoWebView(QWebEngineView): + initialized = Signal() + textChanged = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent=parent) + + self.pylsp_host = get_pylsp_host() + + self._setup_page() + self._setup_bridge() + self._load_editor() + + def _setup_page(self): + """Initialize the web engine page.""" + page = MonacoPage(parent=self) + self.setPage(page) + + def _setup_bridge(self): + """Initialize the bridge for communication with JavaScript.""" + self._channel = QWebChannel(self) + self._bridge = EditorBridge() + + self.page().setWebChannel(self._channel) + self._channel.registerObject("bridge", self._bridge) + + self._bridge.initialized.connect(self._set_host) + self._bridge.initialized.connect(self.initialized) + self._bridge.valueChanged.connect(lambda: self.textChanged.emit(self._bridge.value)) + + def _load_editor(self): + """Load the Monaco Editor HTML content.""" + raw_html = get_monaco_html() + base_url = get_monaco_base_url() + self.setHtml(raw_html, base_url) + + def _set_host(self): + """Set the LSP host once the bridge is initialized.""" + self._bridge.setHost(self.pylsp_host) + + # Public API methods + def text(self): + return self._bridge.value + + def set_text(self, text: str): + self._bridge.setValue(text) + + def set_cursor( + self, + line: int, + column: int = 1, + move_to_position: Literal[None, "center", "top", "position"] = None, + ): + """Set the cursor position in the editor. + + Args: + line (int): Line number (1-based) + column (int): Column number (1-based), defaults to 1 + """ + self._bridge.setCursor(line, column, move_to_position) + + def get_language(self) -> str: + """Get the current programming language for syntax highlighting in the editor.""" + return self._bridge.getLanguage() + + def set_language(self, language: str): + """Set the programming language for syntax highlighting in the editor. + + Args: + language (str): The programming language to set (e.g., "python", "javascript"). + """ + self._bridge.setLanguage(language) + + def get_theme(self): + return self._bridge.getTheme() + + def set_theme(self, theme: str): + """Set the theme for the Monaco editor. + + Args: + theme (str): The theme to apply (e.g., "vs", "vs-dark"). + """ + self._bridge.setTheme(theme) + + def set_read_only(self, read_only: bool): + """Set the editor to read-only mode.""" + self._bridge.setReadOnly(read_only) + + def shutdown(self): + if hasattr(self._bridge, "shutdown"): + self._bridge.shutdown() diff --git a/bec_widgets/widgets/editors/monaco/core/pylsp_provider.py b/bec_widgets/widgets/editors/monaco/core/pylsp_provider.py new file mode 100644 index 00000000..6b68d75d --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/core/pylsp_provider.py @@ -0,0 +1,62 @@ +import atexit +import signal +import socket +import subprocess + +from bec_lib.logger import bec_logger + +logger = bec_logger.logger + + +class PyLSPProvider: + """A provider for the PyLSP server.""" + + def __init__(self): + self.port = None + self.server_process = None + atexit.register(self.stop) + signal.signal(signal.SIGINT, self._handle_signal) + signal.signal(signal.SIGTERM, self._handle_signal) + + def _find_free_port(self): + """Find a free port for the PyLSP server.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("localhost", 0)) + self.port = s.getsockname()[1] + return self.port + + def start(self): + """Start the PyLSP server.""" + if self.port is None: + self._find_free_port() + # Here you would start the PyLSP server using the found port + logger.info(f"Starting PyLSP server on port {self.port}") + self.server_process = subprocess.Popen( + ["pylsp", "--ws", "--port", str(self.port)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def stop(self): + """Stop the PyLSP server.""" + if not self.server_process: + return + + self.server_process.terminate() + try: + self.server_process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.server_process.kill() + self.server_process = None + self.port = None + + def _handle_signal(self, signum, frame): + """Handle termination signals.""" + self.stop() + + def is_running(self): + """Check if the PyLSP server is running.""" + return self.server_process is not None and self.server_process.poll() is None + + +pylsp_server = PyLSPProvider() diff --git a/bec_widgets/widgets/editors/monaco/core/resource_loader.py b/bec_widgets/widgets/editors/monaco/core/resource_loader.py new file mode 100644 index 00000000..dd7a2286 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/core/resource_loader.py @@ -0,0 +1,26 @@ +import os + +from qtpy.QtCore import QFile, QIODevice, QResource, QUrl + +QResource.registerResource(os.path.join(os.path.dirname(__file__), "monaco.rcc")) + + +def load_resource_html(resource_path: str) -> str: + """Load HTML content from Qt resources.""" + file = QFile(resource_path) + if file.open(QIODevice.OpenModeFlag.ReadOnly): + content = file.readAll() + file.close() + return content.toStdString() + else: + raise FileNotFoundError(f"Resource not found: {resource_path}") + + +def get_monaco_html(): + """Get Monaco Editor HTML content from Qt resources.""" + return load_resource_html(":/monaco/dist/index.html") + + +def get_monaco_base_url(): + """Get the base URL for Monaco Editor resources.""" + return QUrl("qrc:/monaco/dist/") 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..dcd4dd72 --- /dev/null +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -0,0 +1,144 @@ +from typing import Literal + +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.widgets.editors.monaco.core.monaco_web_view import MonacoWebView + + +class MonacoWidget(BECWidget, QWidget): + """ + A simple Monaco editor widget + """ + + PLUGIN = True + ICON_NAME = "code" + USER_ACCESS = [ + "set_text", + "get_text", + "set_language", + "set_theme", + "set_read_only", + "set_cursor", + ] + + 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 = MonacoWebView(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.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 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 set_read_only(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_read_only(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 cleanup(self) -> None: + """ + Clean up the widget before closing. + """ + self.editor.shutdown() + super().cleanup() + + +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("github-dark") + 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.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..8d6b5974 --- /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 "" + + 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 4799926c..61482666 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,16 +13,17 @@ classifiers = [ "Topic :: Scientific/Engineering", ] dependencies = [ - "bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console + "bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console "bec_lib>=3.44, <=4.0", "bec_qthemes~=0.7, >=0.7", - "black~=25.0", # needed for bw-generate-cli - "isort~=5.13, >=5.13.2", # needed for bw-generate-cli + "black~=25.0", # needed for bw-generate-cli + "isort~=5.13, >=5.13.2", # needed for bw-generate-cli "pydantic~=2.0", "pyqtgraph~=0.13", "PySide6~=6.8.2", - "qtconsole~=5.5, >=5.5.1", # needed for jupyter console + "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtpy~=2.4", + "python-lsp-server[all,websockets] ~= 1.12", ] diff --git a/tests/unit_tests/test_monaco_editor.py b/tests/unit_tests/test_monaco_editor.py new file mode 100644 index 00000000..b2dbd1f4 --- /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_read_only(True) + + with pytest.raises(ValueError): + monaco_widget.set_text("This should not change") + + monaco_widget.set_read_only(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"