0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-12 18:51:50 +02:00

wip - feat: add monaco editor

This commit is contained in:
2025-05-14 13:10:02 +02:00
parent c50ace5818
commit 6ec31dc1f8
16 changed files with 706 additions and 4 deletions

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -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}")

View File

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

View File

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

View File

@ -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/")

View File

@ -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_()

View File

@ -0,0 +1 @@
{'files': ['monaco_widget.py']}

View File

@ -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 = """
<ui language='c++'>
<widget class='MonacoWidget' name='monaco_widget'>
</widget>
</ui>
"""
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()

View File

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

View File

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

View File

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