1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-13 20:20:55 +02:00

Compare commits

...

14 Commits

Author SHA1 Message Date
8ca5dc992f wip demo of property manager of some widgets 2025-07-31 12:07:32 +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
semantic-release
85ce2aa136 2.32.0
Automatically generated by python-semantic-release
2025-07-29 13:09:07 +00:00
fd5af01842 feat(dock area): add screenshot toolbar action 2025-07-29 15:08:17 +02:00
8a214c8978 feat(rpc_timeout): add decorator to override the rpc timeout 2025-07-29 15:08:17 +02:00
semantic-release
f3214445f2 2.31.3
Automatically generated by python-semantic-release
2025-07-29 12:57:40 +00:00
6bf84aea25 fix(waveform): fallback mechanism for auto mode to use index if scan_report_devices are not available 2025-07-29 14:56:54 +02:00
24 changed files with 880 additions and 29 deletions

View File

@@ -1,6 +1,58 @@
# 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
- **dock area**: Add screenshot toolbar action
([`fd5af01`](https://github.com/bec-project/bec_widgets/commit/fd5af0184279400ca6d8e5d2042f31be88d180f3))
- **rpc_timeout**: Add decorator to override the rpc timeout
([`8a214c8`](https://github.com/bec-project/bec_widgets/commit/8a214c897899d0d94d5f262591a001c127d1b155))
## v2.31.3 (2025-07-29)
### Bug Fixes
- **waveform**: Fallback mechanism for auto mode to use index if scan_report_devices are not
available
([`6bf84ae`](https://github.com/bec-project/bec_widgets/commit/6bf84aea2508ff01fe201c045ec055684da88593))
## v2.31.2 (2025-07-29)
### Bug Fixes

View File

@@ -12,7 +12,7 @@ from typing import Literal, Optional
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module
logger = bec_logger.logger
@@ -414,6 +414,13 @@ class BECDockArea(RPCBase):
dict: The state of the dock area.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
@rpc_call
def restore_state(
self, state: "dict" = None, missing: "Literal['ignore', 'error']" = "ignore", extra="bottom"
@@ -1426,6 +1433,13 @@ class Heatmap(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def color_map(self) -> "str":
@@ -1964,6 +1978,13 @@ class Image(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def color_map(self) -> "str":
@@ -2448,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:
"""
@@ -2521,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."""
@@ -2796,6 +2865,13 @@ class MotorMap(RPCBase):
The font size of the legend font.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def color(self) -> "tuple":
@@ -3201,6 +3277,13 @@ class MultiWaveform(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def highlighted_index(self):
@@ -3415,6 +3498,13 @@ class PositionerBox(RPCBase):
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class PositionerBox2D(RPCBase):
"""Simple Widget to control two positioners in box form"""
@@ -3437,6 +3527,13 @@ class PositionerBox2D(RPCBase):
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class PositionerControlLine(RPCBase):
"""A widget that controls a single device."""
@@ -3450,6 +3547,13 @@ class PositionerControlLine(RPCBase):
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class PositionerGroup(RPCBase):
"""Simple Widget to control a positioner in box form"""
@@ -3908,6 +4012,13 @@ class ScanControl(RPCBase):
Cleanup the BECConnector
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
class ScanProgressBar(RPCBase):
"""Widget to display a progress bar that is hooked up to the scan progress of a scan."""
@@ -4216,6 +4327,13 @@ class ScatterWaveform(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def main_curve(self) -> "ScatterCurve":
@@ -4833,6 +4951,13 @@ class Waveform(RPCBase):
Minimum decimal places for crosshair when dynamic precision is enabled.
"""
@rpc_timeout(None)
@rpc_call
def screenshot(self, file_name: "str | None" = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
@property
@rpc_call
def curves(self) -> "list[Curve]":

View File

@@ -53,7 +53,7 @@ from __future__ import annotations
{base_imports}
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
@@ -180,7 +180,10 @@ class {class_name}(RPCBase):"""
f"Method {method} not found in class {cls.__name__}. "
f"Please check the USER_ACCESS list."
)
if hasattr(obj, "__rpc_timeout__"):
timeout = {"value": obj.__rpc_timeout__}
else:
timeout = {}
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
@@ -205,14 +208,26 @@ class {class_name}(RPCBase):"""
def {method}{str(sig_overload)}: ...
"""
self.content += """
@rpc_call"""
self.content += f"""
{self._rpc_call(timeout)}"""
self.content += f"""
def {method}{str(sig)}:
\"\"\"
{doc}
\"\"\""""
def _rpc_call(self, timeout_info: dict[str, float | None]):
"""
Decorator to mark a method as an RPC call.
This is used to generate the client code for the method.
"""
if not timeout_info:
return "@rpc_call"
timeout = timeout_info.get("value", None)
return f"""
@rpc_timeout({timeout})
@rpc_call"""
def write(self, file_name: str):
"""
Write the content to a file, automatically formatted with black.

View File

@@ -39,6 +39,29 @@ def _transform_args_kwargs(args, kwargs) -> tuple[tuple, dict]:
return tuple(_name_arg(arg) for arg in args), {k: _name_arg(v) for k, v in kwargs.items()}
def rpc_timeout(timeout):
"""
A decorator to set a timeout for an RPC call.
Args:
timeout: The timeout in seconds.
Returns:
The decorated function.
"""
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if "timeout" not in kwargs:
kwargs["timeout"] = timeout
return func(self, *args, **kwargs)
return wrapper
return decorator
def rpc_call(func):
"""
A decorator for calling a function on the server.

View File

@@ -0,0 +1,343 @@
"""
This module provides a revised property editor with a dark theme that groups
properties by their originating class. Each group uses a header row and
alternating row colours to resemble Qt Designer. Dynamic properties are
listed separately. Enumeration and flag properties are displayed as read-only
strings using QMetaEnum conversion functions.
"""
from __future__ import annotations
import sys
from qtpy.QtCore import Qt, QSettings, QByteArray
from qtpy.QtWidgets import (
QApplication,
QWidget,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QHBoxLayout,
QCheckBox,
QSpinBox,
QDoubleSpinBox,
QLineEdit,
QPushButton,
QComboBox,
QLabel,
QFileDialog,
)
from qtpy.QtGui import QColor, QBrush
class WidgetStateManager:
def __init__(self, widget: QWidget) -> None:
self.widget = widget
def save_state(self, filename: str | None = None) -> None:
if not filename:
filename, _ = QFileDialog.getSaveFileName(
self.widget, "Save Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._save_widget_state_qsettings(self.widget, settings)
def load_state(self, filename: str | None = None) -> None:
if not filename:
filename, _ = QFileDialog.getOpenFileName(
self.widget, "Load Settings", "", "INI Files (*.ini)"
)
if filename:
settings = QSettings(filename, QSettings.IniFormat)
self._load_widget_state_qsettings(self.widget, settings)
def _save_widget_state_qsettings(self, widget: QWidget, settings: QSettings) -> None:
if widget.property("skip_settings") is True:
return
meta = widget.metaObject()
widget_name = self._get_full_widget_name(widget)
settings.beginGroup(widget_name)
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if (
name == "objectName"
or not prop.isReadable()
or not prop.isWritable()
or not prop.isStored()
):
continue
value = widget.property(name)
settings.setValue(name, value)
settings.endGroup()
for prop_name in widget.dynamicPropertyNames():
name_str = (
bytes(prop_name).decode()
if isinstance(prop_name, (bytes, bytearray, QByteArray))
else str(prop_name)
)
key = f"{widget_name}/{name_str}"
value = widget.property(name_str)
settings.setValue(key, value)
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._save_widget_state_qsettings(child, settings)
def _load_widget_state_qsettings(self, widget: QWidget, settings: QSettings) -> None:
if widget.property("skip_settings") is True:
return
meta = widget.metaObject()
widget_name = self._get_full_widget_name(widget)
settings.beginGroup(widget_name)
for i in range(meta.propertyCount()):
prop = meta.property(i)
name = prop.name()
if settings.contains(name):
value = settings.value(name)
widget.setProperty(name, value)
settings.endGroup()
prefix = widget_name + "/"
for key in settings.allKeys():
if key.startswith(prefix):
name = key[len(prefix) :]
value = settings.value(key)
widget.setProperty(name, value)
for child in widget.children():
if (
child.objectName()
and child.property("skip_settings") is not True
and not isinstance(child, QLabel)
):
self._load_widget_state_qsettings(child, settings)
def _get_full_widget_name(self, widget: QWidget) -> str:
name = widget.objectName() or widget.metaObject().className()
parent = widget.parent()
while parent:
parent_name = parent.objectName() or parent.metaObject().className()
name = f"{parent_name}.{name}"
parent = parent.parent()
return name
class PropertyEditor(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._target: QWidget | None = None
layout = QVBoxLayout(self)
self.header_label = QLabel(self)
self.header_label.setStyleSheet(
"QLabel { background-color: #323a45; color: #ffffff; font-weight: bold; padding: 4px; }"
)
layout.addWidget(self.header_label)
self._tree = QTreeWidget(self)
self._tree.setColumnCount(2)
self._tree.setHeaderLabels(["Property", "Value"])
self._tree.setRootIsDecorated(False)
self._tree.setAlternatingRowColors(True)
self._tree.setStyleSheet(
"QTreeWidget { background-color: #1e1e1e; alternate-background-color: #252526; color: #d4d4d4; }"
"QHeaderView::section { background-color: #2d2d30; color: #f0f0f0; padding-left: 4px; }"
"QTreeWidget::item { padding: 2px; }"
)
layout.addWidget(self._tree)
def set_target(self, widget: QWidget | None) -> None:
self._tree.clear()
self._target = widget
if widget is None:
self.header_label.setText("")
return
obj_name = widget.objectName() or widget.metaObject().className()
class_name = widget.metaObject().className()
self.header_label.setText(f"{obj_name} : {class_name}")
meta_objs = []
meta = widget.metaObject()
while meta:
meta_objs.append(meta)
meta = meta.superClass()
prop_starts = {m: m.propertyOffset() for m in meta_objs}
for index in range(len(meta_objs) - 1, -1, -1):
current_meta = meta_objs[index]
if index > 0:
next_meta = meta_objs[index - 1]
start = prop_starts[current_meta]
end = prop_starts[next_meta] - 1
else:
start = prop_starts[current_meta]
end = current_meta.propertyCount() - 1
properties_added = False
group_item = QTreeWidgetItem([current_meta.className(), ""])
group_item.setFirstColumnSpanned(True)
group_brush = QBrush(QColor(45, 49, 55))
for col in range(2):
group_item.setBackground(col, group_brush)
group_item.setForeground(col, QBrush(QColor(255, 255, 255)))
for prop_index in range(start, end + 1):
prop = current_meta.property(prop_index)
name = prop.name()
# NOTE: call isDesignable(widget) rather than isDesignable() alone.
if (
name == "objectName"
or not prop.isReadable()
or not prop.isWritable()
or not prop.isStored()
or not prop.isDesignable()
):
continue
value = widget.property(name)
editable = True
if prop.isEnumType() or prop.isFlagType():
enumerator = prop.enumerator()
try:
ivalue = int(value)
except Exception:
ivalue = 0
display_value = (
enumerator.valueToKeys(ivalue)
if prop.isFlagType()
else enumerator.valueToKey(ivalue)
) or str(value)
editable = False
self._add_property_row(group_item, name, display_value, editable)
else:
self._add_property_row(group_item, name, value, editable)
properties_added = True
if properties_added:
self._tree.addTopLevelItem(group_item)
dyn_names = [
(bytes(p).decode() if isinstance(p, (bytes, bytearray, QByteArray)) else str(p))
for p in widget.dynamicPropertyNames()
]
if dyn_names:
dyn_group = QTreeWidgetItem(["Dynamic Properties", ""])
dyn_group.setFirstColumnSpanned(True)
dyn_brush = QBrush(QColor(60, 65, 72))
for col in range(2):
dyn_group.setBackground(col, dyn_brush)
dyn_group.setForeground(col, QBrush(QColor(255, 255, 255)))
for name_str in dyn_names:
value = widget.property(name_str)
self._add_property_row(dyn_group, name_str, value, True)
self._tree.addTopLevelItem(dyn_group)
def _add_property_row(
self, parent: QTreeWidgetItem, name: str, value: object, editable: bool = True
) -> None:
if self._target is None:
return
item = QTreeWidgetItem(parent, [name, ""])
editor: QWidget | None = None
if editable:
if isinstance(value, bool):
editor = QCheckBox(self)
editor.setChecked(value)
editor.stateChanged.connect(
lambda state, prop=name, w=self._target: w.setProperty(prop, bool(state))
)
elif isinstance(value, int) and not isinstance(value, bool):
spin = QSpinBox(self)
spin.setRange(-2147483648, 2147483647)
spin.setValue(int(value))
spin.valueChanged.connect(
lambda val, prop=name, w=self._target: w.setProperty(prop, int(val))
)
editor = spin
elif isinstance(value, float):
dspin = QDoubleSpinBox(self)
dspin.setDecimals(6)
dspin.setRange(-1e12, 1e12)
dspin.setValue(float(value))
dspin.valueChanged.connect(
lambda val, prop=name, w=self._target: w.setProperty(prop, float(val))
)
editor = dspin
elif isinstance(value, str):
line = QLineEdit(self)
line.setText(value)
line.textChanged.connect(
lambda text, prop=name, w=self._target: w.setProperty(prop, str(text))
)
editor = line
# Always use self._tree to assign the editor widget
if editor:
self._tree.setItemWidget(item, 1, editor)
else:
item.setText(1, str(value))
item.setForeground(0, QBrush(QColor(200, 200, 200)))
class ExampleApp(QWidget):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Property Manager Demo")
self.setObjectName("DemoWindow")
main_layout = QVBoxLayout(self)
selector_layout = QHBoxLayout()
selector_label = QLabel("Select widget:")
self.selector = QComboBox()
selector_layout.addWidget(selector_label)
selector_layout.addWidget(self.selector)
main_layout.addLayout(selector_layout)
self.line_edit = QLineEdit()
self.line_edit.setObjectName("DemoLineEdit")
self.line_edit.setText("Hello")
self.spin_box = QSpinBox()
self.spin_box.setObjectName("DemoSpinBox")
self.spin_box.setValue(42)
self.check_box = QCheckBox("Check me")
self.check_box.setObjectName("DemoCheckBox")
self.check_box.setChecked(True)
self.widgets = {
"Line Edit": self.line_edit,
"Spin Box": self.spin_box,
"Check Box": self.check_box,
}
for name in self.widgets:
self.selector.addItem(name)
self.property_editor = PropertyEditor()
btn_layout = QHBoxLayout()
self.save_btn = QPushButton("Save Properties")
self.load_btn = QPushButton("Load Properties")
btn_layout.addWidget(self.save_btn)
btn_layout.addWidget(self.load_btn)
main_layout.addWidget(self.line_edit)
main_layout.addWidget(self.spin_box)
main_layout.addWidget(self.check_box)
main_layout.addWidget(self.property_editor)
main_layout.addLayout(btn_layout)
self.selector.currentTextChanged.connect(self.on_widget_selected)
self.save_btn.clicked.connect(self.on_save_clicked)
self.load_btn.clicked.connect(self.on_load_clicked)
if self.selector.count() > 0:
self.on_widget_selected(self.selector.currentText())
def on_widget_selected(self, name: str) -> None:
widget = self.widgets.get(name)
self.property_editor.set_target(widget)
def on_save_clicked(self) -> None:
name = self.selector.currentText()
widget = self.widgets.get(name)
if widget is not None:
manager = WidgetStateManager(widget)
manager.save_state()
def on_load_clicked(self) -> None:
name = self.selector.currentText()
widget = self.widgets.get(name)
if widget is not None:
manager = WidgetStateManager(widget)
manager.load_state()
if __name__ == "__main__":
app = QApplication(sys.argv)
demo = ExampleApp()
demo.resize(500, 500)
demo.show()
sys.exit(app.exec_())

View File

@@ -1,16 +1,19 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
import darkdetect
import shiboken6
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QApplication
from qtpy.QtCore import QObject
from qtpy.QtWidgets import QApplication, QFileDialog, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.rpc_decorator import rpc_timeout
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock
@@ -88,7 +91,7 @@ class BECWidget(BECConnector):
theme = "dark"
self.apply_theme(theme)
@Slot(str)
@SafeSlot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
@@ -97,6 +100,30 @@ class BECWidget(BECConnector):
theme(str, optional): The theme to be applied.
"""
@SafeSlot()
@SafeSlot(str)
@rpc_timeout(None)
def screenshot(self, file_name: str | None = None):
"""
Take a screenshot of the dock area and save it to a file.
"""
if not isinstance(self, QWidget):
logger.error("Cannot take screenshot of non-QWidget instance")
return
screenshot = self.grab()
if file_name is None:
file_name, _ = QFileDialog.getSaveFileName(
self,
"Save Screenshot",
f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
"PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)",
)
if not file_name:
return
screenshot.save(file_name)
logger.info(f"Screenshot saved to {file_name}")
def cleanup(self):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():

View File

@@ -13,3 +13,17 @@ def register_rpc_methods(cls):
if getattr(method, "rpc_public", False):
cls.USER_ACCESS.add(name)
return cls
def rpc_timeout(timeout: float | None):
"""
Decorator to set a timeout for RPC methods.
The actual implementation of timeout handling is within the cli module. This decorator
is solely to inform the generate-cli command about the timeout value.
"""
def decorator(func):
func.__rpc_timeout__ = timeout # Store the timeout value in the function
return func
return decorator

View File

@@ -71,6 +71,7 @@ class BECDockArea(BECWidget, QWidget):
"detach_dock",
"attach_all",
"save_state",
"screenshot",
"restore_state",
]
@@ -267,11 +268,16 @@ class BECDockArea(BECWidget, QWidget):
"restore_state",
MaterialIconAction(icon_name="frame_reload", tooltip="Restore Dock State", parent=self),
)
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
bundle = ToolbarBundle("dock_actions", self.toolbar.components)
bundle.add_action("attach_all")
bundle.add_action("save_state")
bundle.add_action("restore_state")
bundle.add_action("screenshot")
self.toolbar.add_bundle(bundle)
def _hook_toolbar(self):
@@ -333,6 +339,7 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.components.get_action("restore_state").action.triggered.connect(
self.restore_state
)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:

View File

@@ -33,7 +33,7 @@ class PositionerBox(PositionerBoxBase):
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner"]
USER_ACCESS = ["set_positioner", "screenshot"]
device_changed = Signal(str, str)
# Signal emitted to inform listeners about a position update
position_update = Signal(float)

View File

@@ -34,7 +34,7 @@ class PositionerBox2D(PositionerBoxBase):
PLUGIN = True
RPC = True
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver"]
USER_ACCESS = ["set_positioner_hor", "set_positioner_ver", "screenshot"]
device_changed_hor = Signal(str, str)
device_changed_ver = Signal(str, str)

View File

@@ -45,6 +45,7 @@ class ScanControl(BECWidget, QWidget):
Widget to submit new scans to the queue.
"""
USER_ACCESS = ["remove", "screenshot"]
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2

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

@@ -115,6 +115,7 @@ class Heatmap(ImageBase):
"auto_range_y.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
# ImageView Specific Settings
"color_map",
"color_map.setter",

View File

@@ -91,6 +91,7 @@ class Image(ImageBase):
"auto_range_y.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
# ImageView Specific Settings
"color_map",
"color_map.setter",

View File

@@ -128,6 +128,7 @@ class MotorMap(PlotBase):
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
"screenshot",
# motor_map specific
"color",
"color.setter",

View File

@@ -96,6 +96,7 @@ class MultiWaveform(PlotBase):
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
# MultiWaveform Specific RPC Access
"highlighted_index",
"highlighted_index.setter",

View File

@@ -84,6 +84,7 @@ class ScatterWaveform(PlotBase):
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
# Scatter Waveform Specific RPC Access
"main_curve",
"color_map",

View File

@@ -105,6 +105,7 @@ class Waveform(PlotBase):
"legend_label_size.setter",
"minimal_crosshair_precision",
"minimal_crosshair_precision.setter",
"screenshot",
# Waveform Specific RPC Access
"curves",
"x_mode",
@@ -1634,18 +1635,25 @@ class Waveform(PlotBase):
# 4.2 If there are sync curves, use the first device from the scan report
else:
try:
x_name = self._ensure_str_list(
scan_report_devices = self._ensure_str_list(
self.scan_item.metadata["bec"]["scan_report_devices"]
)[0]
except:
x_name = self.scan_item.status_message.info["scan_report_devices"][0]
x_entry = self.entry_validator.validate_signal(x_name, None)
if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
)
except Exception:
scan_report_devices = self.scan_item.status_message.info.get(
"scan_report_devices", []
)
if not scan_report_devices:
x_data = None
new_suffix = " (auto: index)"
else:
entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {x_name}-{x_entry})"
x_name = scan_report_devices[0]
x_entry = self.entry_validator.validate_signal(x_name, None)
if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
else:
entry_obj = data.get(x_name, {}).get(x_entry)
x_data = entry_obj.read()["value"] if entry_obj else None
new_suffix = f" (auto: {x_name}-{x_entry})"
self._update_x_label_suffix(new_suffix)
return x_data

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.31.2"
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

@@ -1,5 +1,7 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import pytest
from bec_lib.endpoints import MessageEndpoints
@@ -170,3 +172,62 @@ def test_toolbar_add_utils_progress_bar(bec_dock_area):
bec_dock_area.panels["ring_progress_bar_0"].widgets[0].config.widget_class
== "RingProgressBar"
)
def test_toolbar_screenshot_action(bec_dock_area, tmpdir):
"""Test the screenshot functionality from the toolbar."""
# Create a test screenshot file path in tmpdir
screenshot_path = tmpdir.join("test_screenshot.png")
# Mock the QFileDialog.getSaveFileName to return a test filename
with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(screenshot_path), "PNG Files (*.png)")
# Mock the screenshot.save method
with mock.patch.object(bec_dock_area, "grab") as mock_grab:
mock_screenshot = mock.MagicMock()
mock_grab.return_value = mock_screenshot
# Trigger the screenshot action
bec_dock_area.toolbar.components.get_action("screenshot").action.trigger()
# Verify the dialog was called with correct parameters
mock_dialog.assert_called_once()
call_args = mock_dialog.call_args[0]
assert call_args[0] == bec_dock_area # parent widget
assert call_args[1] == "Save Screenshot" # dialog title
assert call_args[2].startswith("bec_") # filename starts with bec_
assert call_args[2].endswith(".png") # filename ends with .png
assert (
call_args[3] == "PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)"
) # file filter
# Verify grab was called
mock_grab.assert_called_once()
# Verify save was called with the filename
mock_screenshot.save.assert_called_once_with(str(screenshot_path))
def test_toolbar_screenshot_action_cancelled(bec_dock_area):
"""Test the screenshot functionality when user cancels the dialog."""
# Mock the QFileDialog.getSaveFileName to return empty filename (cancelled)
with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog:
mock_dialog.return_value = ("", "")
# Mock the screenshot.save method
with mock.patch.object(bec_dock_area, "grab") as mock_grab:
mock_screenshot = mock.MagicMock()
mock_grab.return_value = mock_screenshot
# Trigger the screenshot action
bec_dock_area.toolbar.components.get_action("screenshot").action.trigger()
# Verify the dialog was called
mock_dialog.assert_called_once()
# Verify grab was called (screenshot is taken before dialog)
mock_grab.assert_called_once()
# Verify save was NOT called since dialog was cancelled
mock_screenshot.save.assert_not_called()

View File

@@ -79,7 +79,7 @@ def test_client_generator_with_black_formatting():
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout
from bec_widgets.utils.bec_plugin_helper import (get_all_plugin_widgets,
get_plugin_client_module)

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