1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-18 14:25:37 +02:00

Compare commits

...

11 Commits

Author SHA1 Message Date
3bda713be7 wip dock area with custom style 2025-07-30 16:39:37 +02:00
66c64cd0b1 wip dock area with icons 2025-07-30 16:39:37 +02:00
c428a2829a wip advanced dock area simple 2025-07-30 16:39:37 +02:00
semantic-release
e2b8118f67 2.33.0
Automatically generated by python-semantic-release
2025-07-29 13:24:20 +00:00
5f925ba4e3 build: update bec and qtmonaco min dependencies 2025-07-29 15:23:36 +02:00
fc68d2cf2d feat(monaco): add insert, delete and lsp header 2025-07-29 15:23:36 +02:00
627b49b33a feat(monaco): add vim mode 2025-07-29 15:23:36 +02:00
a51ef04cdf fix(monaco): forward text changed signal 2025-07-29 15:23:36 +02:00
40f4bce285 test(web console): add tests for the web console 2025-07-29 15:23:36 +02:00
2b9fe6c959 feat(web console): add signal to indicate when the js backend is initialized 2025-07-29 15:23:36 +02:00
c2e16429c9 feat(web console): add set_readonly method 2025-07-29 15:23:36 +02:00
8 changed files with 386 additions and 8 deletions

View File

@@ -1,6 +1,38 @@
# CHANGELOG
## v2.33.0 (2025-07-29)
### Bug Fixes
- **monaco**: Forward text changed signal
([`a51ef04`](https://github.com/bec-project/bec_widgets/commit/a51ef04cdf0ac8abdb7008d78b13c75b86ce9e06))
### Build System
- Update bec and qtmonaco min dependencies
([`5f925ba`](https://github.com/bec-project/bec_widgets/commit/5f925ba4e3840219e4473d6346ece6746076f718))
### Features
- **monaco**: Add insert, delete and lsp header
([`fc68d2c`](https://github.com/bec-project/bec_widgets/commit/fc68d2cf2d6b161d8e3b9fc9daf6185d9197deba))
- **monaco**: Add vim mode
([`627b49b`](https://github.com/bec-project/bec_widgets/commit/627b49b33a30e45b2bfecb57f090eecfa31af09d))
- **web console**: Add set_readonly method
([`c2e1642`](https://github.com/bec-project/bec_widgets/commit/c2e16429c91de7cc0e672ba36224e9031c1c4234))
- **web console**: Add signal to indicate when the js backend is initialized
([`2b9fe6c`](https://github.com/bec-project/bec_widgets/commit/2b9fe6c9590c8d18b7542307273176e118828681))
### Testing
- **web console**: Add tests for the web console
([`40f4bce`](https://github.com/bec-project/bec_widgets/commit/40f4bce2854bcf333ce261229bd1703b80ced538))
## v2.32.0 (2025-07-29)
### Features

View File

@@ -2469,6 +2469,26 @@ class MonacoWidget(RPCBase):
Get the current text from the Monaco editor.
"""
@rpc_call
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
Args:
text (str): The text to insert.
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
"""
@rpc_call
def delete_line(self, line: int | None = None) -> None:
"""
Delete a line in the Monaco editor.
Args:
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
"""
@rpc_call
def set_language(self, language: str) -> None:
"""
@@ -2542,6 +2562,34 @@ class MonacoWidget(RPCBase):
enabled (bool): If True, the minimap will be enabled; otherwise, it will be disabled.
"""
@rpc_call
def set_vim_mode_enabled(self, enabled: bool) -> None:
"""
Enable or disable Vim mode in the Monaco editor.
Args:
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
"""
@rpc_call
def set_lsp_header(self, header: str) -> None:
"""
Set the LSP (Language Server Protocol) header for the Monaco editor.
The header is used to provide context for language servers but is not displayed in the editor.
Args:
header (str): The LSP header to set.
"""
@rpc_call
def get_lsp_header(self) -> str:
"""
Get the current LSP header set in the Monaco editor.
Returns:
str: The LSP header.
"""
class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""

View File

@@ -0,0 +1,128 @@
import PySide6QtAds as QtAds
from bec_qthemes import material_icon
from qtpy.QtWidgets import QApplication
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
# ── Fancy dock styles ──
_LIGHT_DOCK_QSS = """
QDockWidget {
background: #fafafa;
border: 1px solid #ccc;
border-radius: 4px;
}
QTabBar::tab {
background: #e0e0e0;
padding: 6px;
margin: 1px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
color: #333;
}
QTabBar::tab:selected {
background: #fff;
font-weight: bold;
}
"""
_DARK_DOCK_QSS = """
QDockWidget {
background: #2b2b2b;
border: 1px solid #444;
border-radius: 4px;
}
QTabBar::tab {
background: #3c3f41;
padding: 6px;
margin: 1px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
color: #bbb;
}
QTabBar::tab:selected {
background: #323232;
color: #fff;
font-weight: bold;
}
"""
class AdvancedDockArea(BECMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Advanced Dock Area Example")
self.setMinimumSize(1400, 1000)
# Set the dock manager configuration flags
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.DockAreaDynamicTabsMenuButtonVisibility, True
)
# Initialize the dock manager
self.dock_manager = QtAds.CDockManager(self)
# Some bec widgets setup
self.wf1 = Waveform()
self.wf1.plot("bpm4i")
self.sc = ScanControl()
self.qw = BECQueue()
self.monaco_1 = MonacoWidget()
self.monaco_2 = MonacoWidget()
# put them into docks
self.dock_wf1 = QtAds.CDockWidget("Waveform 1")
self.dock_wf1.setWidget(self.wf1)
self.dock_wf1.setIcon(material_icon(self.wf1.ICON_NAME))
self.dock_sc = QtAds.CDockWidget("Scan Control")
self.dock_sc.setWidget(self.sc)
self.dock_sc.setIcon(material_icon(self.sc.ICON_NAME))
self.dock_qw = QtAds.CDockWidget("Queue")
self.dock_qw.setWidget(self.qw)
self.dock_qw.setIcon(material_icon(self.qw.ICON_NAME))
# Create Monaco editor docks
self.dock_monaco_1 = QtAds.CDockWidget("Script 1")
self.dock_monaco_1.setWidget(self.monaco_1)
self.dock_monaco_1.setIcon(material_icon(self.monaco_1.ICON_NAME))
self.dock_monaco_2 = QtAds.CDockWidget("Script 2")
self.dock_monaco_2.setWidget(self.monaco_2)
self.dock_monaco_2.setIcon(material_icon(self.monaco_2.ICON_NAME))
# Put docks into the dock manager
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, self.dock_sc)
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, self.dock_qw)
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.TopDockWidgetArea, self.dock_wf1)
editor_area = self.dock_manager.addDockWidgetTab(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.dock_monaco_1
)
self.dock_manager.addDockWidgetTabToArea(self.dock_monaco_2, editor_area)
theme = getattr(self.app, "theme", None)
current = theme.theme if theme else "light"
qss = _DARK_DOCK_QSS if current.lower() == "dark" else _LIGHT_DOCK_QSS
self.dock_manager.setStyleSheet(qss)
editor_area.resize(500, 1000)
def change_theme(self, theme: str):
# call base so palettes, icons, etc. switch
super().change_theme(theme)
# then reapply our dock QSS
qss = _DARK_DOCK_QSS if theme.lower() == "dark" else _LIGHT_DOCK_QSS
self.dock_manager.setStyleSheet(qss)
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
main_window = AdvancedDockArea()
main_window.show()
sys.exit(app.exec())

View File

@@ -1,6 +1,7 @@
from typing import Literal
import qtmonaco
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
@@ -12,11 +13,14 @@ class MonacoWidget(BECWidget, QWidget):
A simple Monaco editor widget
"""
text_changed = Signal(str)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
"set_text",
"get_text",
"insert_text",
"delete_line",
"set_language",
"get_language",
"set_theme",
@@ -25,6 +29,9 @@ class MonacoWidget(BECWidget, QWidget):
"set_cursor",
"current_cursor",
"set_minimap_enabled",
"set_vim_mode_enabled",
"set_lsp_header",
"get_lsp_header",
]
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
@@ -36,6 +43,7 @@ class MonacoWidget(BECWidget, QWidget):
self.editor = qtmonaco.Monaco(self)
layout.addWidget(self.editor)
self.setLayout(layout)
self.editor.text_changed.connect(self.text_changed.emit)
self.editor.initialized.connect(self.apply_theme)
def apply_theme(self, theme: str | None = None) -> None:
@@ -65,6 +73,26 @@ class MonacoWidget(BECWidget, QWidget):
"""
return self.editor.get_text()
def insert_text(self, text: str, line: int | None = None, column: int | None = None) -> None:
"""
Insert text at the current cursor position or at a specified line and column.
Args:
text (str): The text to insert.
line (int, optional): The line number (1-based) to insert the text at. Defaults to None.
column (int, optional): The column number (1-based) to insert the text at. Defaults to None.
"""
self.editor.insert_text(text, line, column)
def delete_line(self, line: int | None = None) -> None:
"""
Delete a line in the Monaco editor.
Args:
line (int, optional): The line number (1-based) to delete. If None, the current line will be deleted.
"""
self.editor.delete_line(line)
def set_cursor(
self,
line: int,
@@ -154,6 +182,34 @@ class MonacoWidget(BECWidget, QWidget):
"""
self.editor.clear_highlighted_lines()
def set_vim_mode_enabled(self, enabled: bool) -> None:
"""
Enable or disable Vim mode in the Monaco editor.
Args:
enabled (bool): If True, Vim mode will be enabled; otherwise, it will be disabled.
"""
self.editor.set_vim_mode_enabled(enabled)
def set_lsp_header(self, header: str) -> None:
"""
Set the LSP (Language Server Protocol) header for the Monaco editor.
The header is used to provide context for language servers but is not displayed in the editor.
Args:
header (str): The LSP header to set.
"""
self.editor.set_lsp_header(header)
def get_lsp_header(self) -> str:
"""
Get the current LSP header set in the Monaco editor.
Returns:
str: The LSP header.
"""
return self.editor.get_lsp_header()
if __name__ == "__main__": # pragma: no cover
qapp = QApplication([])

View File

@@ -6,11 +6,12 @@ import time
from bec_lib.logger import bec_logger
from louie.saferef import safe_ref
from qtpy.QtCore import QUrl, qInstallMessageHandler
from qtpy.QtCore import QTimer, QUrl, Signal, qInstallMessageHandler
from qtpy.QtWebEngineWidgets import QWebEnginePage, QWebEngineView
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
logger = bec_logger.logger
@@ -165,11 +166,16 @@ class WebConsole(BECWidget, QWidget):
A simple widget to display a website
"""
_js_callback = Signal(bool)
initialized = Signal()
PLUGIN = True
ICON_NAME = "terminal"
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, **kwargs)
self._startup_cmd = "bec --nogui"
self._is_initialized = False
_web_console_registry.register(self)
self._token = _web_console_registry._token
layout = QVBoxLayout()
@@ -181,6 +187,48 @@ class WebConsole(BECWidget, QWidget):
layout.addWidget(self.browser)
self.setLayout(layout)
self.page.setUrl(QUrl(f"http://localhost:{_web_console_registry._server_port}"))
self._startup_timer = QTimer()
self._startup_timer.setInterval(500)
self._startup_timer.timeout.connect(self._check_page_ready)
self._startup_timer.start()
self._js_callback.connect(self._on_js_callback)
def _check_page_ready(self):
"""
Check if the page is ready and stop the timer if it is.
"""
if self.page.isLoading():
return
self.page.runJavaScript("window.term !== undefined", self._js_callback.emit)
def _on_js_callback(self, ready: bool):
"""
Callback for when the JavaScript is ready.
"""
if not ready:
return
self._is_initialized = True
self._startup_timer.stop()
if self._startup_cmd:
self.write(self._startup_cmd)
self.initialized.emit()
@SafeProperty(str)
def startup_cmd(self):
"""
Get the startup command for the web console.
"""
return self._startup_cmd
@startup_cmd.setter
def startup_cmd(self, cmd: str):
"""
Set the startup command for the web console.
"""
if not isinstance(cmd, str):
raise ValueError("Startup command must be a string.")
self._startup_cmd = cmd
def write(self, data: str, send_return: bool = True):
"""
@@ -213,10 +261,19 @@ class WebConsole(BECWidget, QWidget):
"document.querySelector('textarea.xterm-helper-textarea').dispatchEvent(new KeyboardEvent('keypress', {charCode: 3}))"
)
def set_readonly(self, readonly: bool):
"""
Set the web console to read-only mode.
"""
if not isinstance(readonly, bool):
raise ValueError("Readonly must be a boolean.")
self.setEnabled(not readonly)
def cleanup(self):
"""
Clean up the registry by removing any instances that are no longer valid.
"""
self._startup_timer.stop()
_web_console_registry.unregister(self)
super().cleanup()

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.32.0"
version = "2.33.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -13,17 +13,17 @@ classifiers = [
"Topic :: Scientific/Engineering",
]
dependencies = [
"bec_ipython_client>=3.42.4, <=4.0", # needed for jupyter console
"bec_lib>=3.44, <=4.0",
"bec_ipython_client~=3.52", # needed for jupyter console
"bec_lib~=3.52",
"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",
"qtmonaco>=0.2.3",
"qtmonaco~=0.5",
]

View File

@@ -88,3 +88,60 @@ def test_web_console_registry_wait_for_server_port_timeout():
with mock.patch.object(_web_console_registry, "_server_process") as mock_subprocess:
with pytest.raises(TimeoutError):
_web_console_registry._wait_for_server_port(timeout=0.1)
def test_web_console_startup_command_execution(console_widget, qtbot):
"""Test that the startup command is triggered after successful initialization."""
# Set a custom startup command
console_widget.startup_cmd = "test startup command"
assert console_widget.startup_cmd == "test startup command"
# Generator to simulate JS initialization sequence
def js_readiness_sequence():
yield False # First call: not ready yet
while True:
yield True # Any subsequent calls: ready
readiness_gen = js_readiness_sequence()
def mock_run_js(script, callback=None):
# Check if this is the initialization check call
if "window.term !== undefined" in script and callback:
ready = next(readiness_gen)
callback(ready)
else:
# For other JavaScript calls (like paste), just call the callback
if callback:
callback(True)
with mock.patch.object(
console_widget.page, "runJavaScript", side_effect=mock_run_js
) as mock_run_js_method:
# Reset initialization state and start the timer
console_widget._is_initialized = False
console_widget._startup_timer.start()
# Wait for the initialization to complete
qtbot.waitUntil(lambda: console_widget._is_initialized, timeout=3000)
# Verify that the startup command was executed
startup_calls = [
call
for call in mock_run_js_method.call_args_list
if "test startup command" in str(call)
]
assert len(startup_calls) > 0, "Startup command should have been executed"
# Verify the initialized signal was emitted
assert console_widget._is_initialized is True
assert not console_widget._startup_timer.isActive()
def test_web_console_set_readonly(console_widget):
# Test the set_readonly method
console_widget.set_readonly(True)
assert not console_widget.isEnabled()
console_widget.set_readonly(False)
assert console_widget.isEnabled()