mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-15 09:32:20 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 733bc04e39 | |||
| fa06da1ed6 | |||
| bbcefbb88f | |||
| a185f6f5fe | |||
| c63aa01757 | |||
| 7e469627a2 |
@@ -1,14 +1,6 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.27.1 (2025-07-17)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **image_roi_tree**: Rois signals are disconnected when roi tree widget is closed
|
||||
([`00e3713`](https://github.com/bec-project/bec_widgets/commit/00e3713181916a432e4e9dec8a0d80205914cf77))
|
||||
|
||||
|
||||
## v2.27.0 (2025-07-17)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication, QFileDialog, QFrame, QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScriptInterface(BECWidget, QWidget):
|
||||
"""
|
||||
A simple script interface widget that allows interaction with Monaco editor and Web Console.
|
||||
"""
|
||||
|
||||
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, theme_update=True, **kwargs
|
||||
)
|
||||
self.current_script_id = ""
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.toolbar = ModularToolBar(parent=self, orientation="horizontal")
|
||||
|
||||
self.splitter = QSplitter(self)
|
||||
self.splitter.setObjectName("splitter")
|
||||
self.splitter.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.splitter.setOrientation(Qt.Orientation.Vertical)
|
||||
self.splitter.setChildrenCollapsible(True)
|
||||
|
||||
self.monaco_editor = MonacoWidget(self)
|
||||
self.splitter.addWidget(self.monaco_editor)
|
||||
self.web_console = WebConsole(self)
|
||||
self.splitter.addWidget(self.web_console)
|
||||
layout.addWidget(self.toolbar)
|
||||
|
||||
layout.addWidget(self.splitter)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
self.toolbar.components.add_safe(
|
||||
"new_script", MaterialIconAction("add", "New Script", parent=self)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"open", MaterialIconAction("folder_open", "Open Script", parent=self)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"save", MaterialIconAction("save", "Save Script", parent=self)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"run", MaterialIconAction("play_arrow", "Run Script", parent=self)
|
||||
)
|
||||
self.toolbar.components.add_safe(
|
||||
"stop", MaterialIconAction("stop", "Stop Script", parent=self)
|
||||
)
|
||||
|
||||
bundle = ToolbarBundle("file_io", self.toolbar.components)
|
||||
bundle.add_action("new_script")
|
||||
bundle.add_action("open")
|
||||
bundle.add_action("save")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
bundle = ToolbarBundle("script_execution", self.toolbar.components)
|
||||
bundle.add_action("run")
|
||||
bundle.add_action("stop")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
self.toolbar.components.get_action("open").action.triggered.connect(self.open_file_dialog)
|
||||
self.toolbar.components.get_action("run").action.triggered.connect(self.run_script)
|
||||
self.toolbar.components.get_action("stop").action.triggered.connect(
|
||||
self.web_console.send_ctrl_c
|
||||
)
|
||||
|
||||
self.set_save_button_enabled(False)
|
||||
|
||||
self.toolbar.show_bundles(["file_io", "script_execution"])
|
||||
self.web_console.set_readonly(True)
|
||||
self._init_file_content = ""
|
||||
|
||||
self._text_changed_proxy = pg.SignalProxy(
|
||||
self.monaco_editor.text_changed, rateLimit=1, slot=self._on_text_changed
|
||||
)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_text_changed(self, text: str):
|
||||
"""
|
||||
Handle text changes in the Monaco editor.
|
||||
"""
|
||||
text = text[0]
|
||||
if text != self._init_file_content:
|
||||
self.set_save_button_enabled(True)
|
||||
else:
|
||||
self.set_save_button_enabled(False)
|
||||
|
||||
@property
|
||||
def current_script_id(self):
|
||||
return self._current_script_id
|
||||
|
||||
@current_script_id.setter
|
||||
def current_script_id(self, value):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Script ID must be a string.")
|
||||
self._current_script_id = value
|
||||
self._update_subscription()
|
||||
|
||||
def _update_subscription(self):
|
||||
if self.current_script_id:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self.on_script_execution_info,
|
||||
MessageEndpoints.script_execution_info(self.current_script_id),
|
||||
)
|
||||
else:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_script_execution_info,
|
||||
MessageEndpoints.script_execution_info(self.current_script_id),
|
||||
)
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_script_execution_info(self, content: dict, metadata: dict):
|
||||
print(f"Script execution info: {content}")
|
||||
current_lines = content.get("current_lines")
|
||||
if not current_lines:
|
||||
self.monaco_editor.clear_highlighted_lines()
|
||||
return
|
||||
line_number = current_lines[0]
|
||||
self.monaco_editor.clear_highlighted_lines()
|
||||
self.monaco_editor.set_highlighted_lines(line_number, line_number)
|
||||
|
||||
def open_file_dialog(self):
|
||||
"""
|
||||
Open a file dialog to select a script file.
|
||||
"""
|
||||
start_dir = "./"
|
||||
dialog = QFileDialog(self)
|
||||
dialog.setDirectory(start_dir)
|
||||
dialog.setNameFilter("Python Files (*.py);;All Files (*)")
|
||||
dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
|
||||
|
||||
if dialog.exec():
|
||||
selected_files = dialog.selectedFiles()
|
||||
if not selected_files:
|
||||
return
|
||||
file_path = selected_files[0]
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
content = file.read()
|
||||
self.monaco_editor.set_text(content)
|
||||
self._init_file_content = content
|
||||
|
||||
logger.info(f"Selected files: {selected_files}")
|
||||
|
||||
def set_save_button_enabled(self, enabled: bool):
|
||||
"""
|
||||
Set the save button enabled state.
|
||||
"""
|
||||
action = self.toolbar.components.get_action("save")
|
||||
if action:
|
||||
action.action.setEnabled(enabled)
|
||||
|
||||
def run_script(self):
|
||||
print("Running script...")
|
||||
script_id = str(uuid.uuid4())
|
||||
self.current_script_id = script_id
|
||||
script_text = self.monaco_editor.get_text()
|
||||
|
||||
script_text = f'bec._run_script("{script_id}", """{script_text}""")'
|
||||
script_text = script_text.replace("\n", "\\n").replace("'", "\\'").strip()
|
||||
if not script_text.endswith("\n"):
|
||||
script_text += "\\n"
|
||||
self.web_console.write(script_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
script_interface = ScriptInterface()
|
||||
script_interface.resize(800, 600)
|
||||
script_interface.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -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,6 +13,7 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
A simple Monaco editor widget
|
||||
"""
|
||||
|
||||
text_changed = Signal(str)
|
||||
PLUGIN = True
|
||||
ICON_NAME = "code"
|
||||
USER_ACCESS = [
|
||||
@@ -36,6 +38,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:
|
||||
|
||||
@@ -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(1000)
|
||||
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()
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QEvent, Qt
|
||||
from qtpy.QtGui import QColor
|
||||
@@ -40,9 +39,6 @@ if TYPE_CHECKING:
|
||||
from bec_widgets.widgets.plots.image.image import Image
|
||||
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ROILockButton(QToolButton):
|
||||
"""Keeps its icon and checked state in sync with a single ROI."""
|
||||
|
||||
@@ -451,18 +447,6 @@ class ROIPropertyTree(BECWidget, QWidget):
|
||||
def cleanup(self):
|
||||
self.cmap.close()
|
||||
self.cmap.deleteLater()
|
||||
if self.controller and hasattr(self.controller, "rois"):
|
||||
for roi in self.controller.rois: # disconnect all signals from ROIs
|
||||
try:
|
||||
if isinstance(roi, RectangularROI):
|
||||
roi.edgesChanged.disconnect()
|
||||
else:
|
||||
roi.centerChanged.disconnect()
|
||||
roi.penChanged.disconnect()
|
||||
roi.nameChanged.disconnect()
|
||||
except (RuntimeError, TypeError) as e:
|
||||
logger.error(f"Failed to disconnect roi qt signal: {e}")
|
||||
|
||||
super().cleanup()
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.27.1"
|
||||
version = "2.27.0"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
||||
@@ -399,35 +399,3 @@ def test_new_roi_respects_global_lock(roi_tree, image_widget, qtbot):
|
||||
assert not roi.movable
|
||||
# Disable global lock again
|
||||
roi_tree.lock_all_action.action.setChecked(False)
|
||||
|
||||
|
||||
def test_cleanup_disconnect_signals(roi_tree, image_widget):
|
||||
"""Test that cleanup disconnects ROI signals so further changes do not update the tree."""
|
||||
# Add a rectangular ROI
|
||||
roi = image_widget.add_roi(kind="rect", name="cleanup_test", pos=(10, 10), size=(20, 20))
|
||||
item = roi_tree.roi_items[roi]
|
||||
|
||||
# Test that signals are connected before cleanup
|
||||
pre_name = item.text(roi_tree.COL_ROI)
|
||||
pre_coord = item.child(2).text(roi_tree.COL_PROPS)
|
||||
# Change ROI properties to see updates
|
||||
roi.label = "connected_name"
|
||||
roi.setPos(30, 30)
|
||||
# Verify that the tree item updated
|
||||
assert item.text(roi_tree.COL_ROI) == "connected_name"
|
||||
assert item.child(2).text(roi_tree.COL_PROPS) != pre_coord
|
||||
|
||||
# Perform cleanup to disconnect signals
|
||||
roi_tree.cleanup()
|
||||
|
||||
# Store initial state
|
||||
initial_name = item.text(roi_tree.COL_ROI)
|
||||
initial_coord = item.child(2).text(roi_tree.COL_PROPS)
|
||||
|
||||
# Change ROI properties after cleanup
|
||||
roi.label = "changed_name"
|
||||
roi.setPos(50, 50)
|
||||
|
||||
# Verify that the tree item was not updated
|
||||
assert item.text(roi_tree.COL_ROI) == initial_name
|
||||
assert item.child(2).text(roi_tree.COL_PROPS) == initial_coord
|
||||
|
||||
Reference in New Issue
Block a user