mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-10 18:50:55 +02:00
Compare commits
2 Commits
feat/devel
...
improve-de
| Author | SHA1 | Date | |
|---|---|---|---|
| 797a5046d1 | |||
| 9ff0db4831 |
@@ -7,7 +7,6 @@ from bec_widgets.applications.views.device_manager_view.device_manager_widget im
|
||||
DeviceManagerWidget,
|
||||
)
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.examples.developer_view.developer_view import DeveloperView
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
@@ -49,7 +48,6 @@ class BECMainApp(BECMainWindow):
|
||||
self.add_section("BEC Applications", "bec_apps")
|
||||
self.ads = AdvancedDockArea(self)
|
||||
self.device_manager = DeviceManagerWidget(self)
|
||||
self.developer_view = DeveloperView(self)
|
||||
|
||||
self.add_view(
|
||||
icon="widgets", title="Dock Area", id="dock_area", widget=self.ads, mini_text="Docks"
|
||||
@@ -61,13 +59,6 @@ class BECMainApp(BECMainWindow):
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
)
|
||||
self.add_view(
|
||||
icon="code_blocks",
|
||||
title="IDE",
|
||||
widget=self.developer_view,
|
||||
id="developer_view",
|
||||
exclusive=True,
|
||||
)
|
||||
|
||||
if self._show_examples:
|
||||
self.add_section("Examples", "examples")
|
||||
@@ -151,8 +142,6 @@ class BECMainApp(BECMainWindow):
|
||||
# Wrap plain widgets into a ViewBase so enter/exit hooks are available
|
||||
if isinstance(widget, ViewBase):
|
||||
view_widget = widget
|
||||
view_widget.view_id = id
|
||||
view_widget.view_title = title
|
||||
else:
|
||||
view_widget = ViewBase(content=widget, parent=self, id=id, title=title)
|
||||
|
||||
@@ -206,21 +195,7 @@ if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication([sys.argv[0], *qt_args])
|
||||
apply_theme("dark")
|
||||
w = BECMainApp(show_examples=args.examples)
|
||||
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
w.resize(width, height)
|
||||
w.resize(1920, 1200)
|
||||
w.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
@@ -14,10 +14,23 @@ from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
|
||||
from bec_qthemes import apply_theme
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtCore import Qt, QThreadPool, QTimer
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QSplitter, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
@@ -83,6 +96,53 @@ def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
|
||||
class ConfigChoiceDialog(QDialog):
|
||||
REPLACE = 1
|
||||
ADD = 2
|
||||
CANCEL = 0
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Load Config")
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
label = QLabel("Do you want to replace the current config or add to it?")
|
||||
label.setWordWrap(True)
|
||||
layout.addWidget(label)
|
||||
|
||||
# Buttons: equal size, stacked vertically
|
||||
self.replace_btn = QPushButton("Replace")
|
||||
self.add_btn = QPushButton("Add")
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
btn_layout = QHBoxLayout()
|
||||
for btn in (self.replace_btn, self.add_btn, self.cancel_btn):
|
||||
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
btn_layout.addWidget(btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
# Connect signals to explicit slots
|
||||
self.replace_btn.clicked.connect(self.accept_replace)
|
||||
self.add_btn.clicked.connect(self.accept_add)
|
||||
self.cancel_btn.clicked.connect(self.reject_cancel)
|
||||
|
||||
self._result = self.CANCEL
|
||||
|
||||
def accept_replace(self):
|
||||
self._result = self.REPLACE
|
||||
self.accept()
|
||||
|
||||
def accept_add(self):
|
||||
self._result = self.ADD
|
||||
self.accept()
|
||||
|
||||
def reject_cancel(self):
|
||||
self._result = self.CANCEL
|
||||
self.reject()
|
||||
|
||||
def result(self):
|
||||
return self._result
|
||||
|
||||
|
||||
class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
def __init__(self, parent=None, *args, **kwargs):
|
||||
@@ -99,14 +159,14 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Available Resources Widget
|
||||
self.available_devices = AvailableDeviceResources(
|
||||
self, shared_selection_signal=self._shared_selection
|
||||
)
|
||||
self.available_devices_dock = QtAds.CDockWidget(
|
||||
self.dock_manager, "Available Devices", self
|
||||
)
|
||||
self.available_devices_dock.setWidget(self.available_devices)
|
||||
# # Available Resources Widget
|
||||
# self.available_devices = AvailableDeviceResources(
|
||||
# self, shared_selection_signal=self._shared_selection
|
||||
# )
|
||||
# self.available_devices_dock = QtAds.CDockWidget(
|
||||
# self.dock_manager, "Available Devices", self
|
||||
# )
|
||||
# self.available_devices_dock.setWidget(self.available_devices)
|
||||
|
||||
# Device Table View widget
|
||||
self.device_table_view = DeviceTableView(
|
||||
@@ -130,29 +190,62 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
self.ophyd_test_dock_view = QtAds.CDockWidget(self.dock_manager, "Ophyd Test View", self)
|
||||
self.ophyd_test_dock_view.setWidget(self.ophyd_test_view)
|
||||
|
||||
# Arrange widgets within the QtAds dock manager
|
||||
# Help Inspector
|
||||
widget = QWidget(self)
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
self.help_inspector = HelpInspector(self)
|
||||
layout.addWidget(self.help_inspector)
|
||||
text_box = QTextEdit(self)
|
||||
text_box.setReadOnly(False)
|
||||
text_box.setPlaceholderText("Help text will appear here...")
|
||||
layout.addWidget(text_box)
|
||||
self.help_inspector_dock = QtAds.CDockWidget(self.dock_manager, "Help Inspector", self)
|
||||
self.help_inspector_dock.setWidget(widget)
|
||||
|
||||
# # Hook inspector signals
|
||||
# def _class_cb(text: str):
|
||||
# print(text)
|
||||
|
||||
# # Register callback
|
||||
self.help_inspector.bec_widget_help.connect(text_box.setMarkdown)
|
||||
|
||||
# Error Logs View
|
||||
self.error_logs_view = QTextEdit(self)
|
||||
self.error_logs_view.setReadOnly(True)
|
||||
self.error_logs_view.setPlaceholderText("Error logs will appear here...")
|
||||
self.error_logs_dock = QtAds.CDockWidget(self.dock_manager, "Error Logs", self)
|
||||
self.error_logs_dock.setWidget(self.error_logs_view)
|
||||
self.ophyd_test_view.validation_msg_md.connect(self.error_logs_view.setMarkdown)
|
||||
|
||||
# Arrange widgets within the QtAds dock manager
|
||||
# Central widget area
|
||||
self.central_dock_area = self.dock_manager.setCentralWidget(self.device_table_view_dock)
|
||||
# Right area - should be pushed into view if something is active
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea,
|
||||
self.dm_docs_view_dock,
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
self.ophyd_test_dock_view,
|
||||
self.central_dock_area,
|
||||
)
|
||||
|
||||
# Left Area
|
||||
self.left_dock_area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.available_devices_dock
|
||||
)
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_config_view_dock, self.left_dock_area
|
||||
# create bottom area (2-arg -> area)
|
||||
self.bottom_dock_area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea, self.dm_docs_view_dock
|
||||
)
|
||||
|
||||
# Right area
|
||||
# YAML view left of docstrings (docks relative to bottom area)
|
||||
self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea, self.ophyd_test_dock_view
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.dm_config_view_dock, self.bottom_dock_area
|
||||
)
|
||||
|
||||
# Error/help area right of docstrings (dock relative to bottom area)
|
||||
area = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea,
|
||||
self.help_inspector_dock,
|
||||
self.bottom_dock_area,
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.error_logs_dock, area)
|
||||
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
|
||||
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
|
||||
@@ -160,13 +253,16 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetMovable, False)
|
||||
|
||||
# TODO decide if we like to hide the title bars..
|
||||
# Fetch all dock areas of the dock widgets (on our case always one dock area)
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
area = dock.dockAreaWidget()
|
||||
area.titleBar().setVisible(False)
|
||||
# for dock in self.dock_manager.dockWidgets():
|
||||
# if dock.objectName() in ["Help Inspector", "Error Logs"]:
|
||||
# continue
|
||||
# area = dock.dockAreaWidget()
|
||||
# area.titleBar().setVisible(False)
|
||||
|
||||
# Apply stretch after the layout is done
|
||||
self.set_default_view([2, 8, 2], [3, 1])
|
||||
self.set_default_view([2, 8, 2], [7, 3])
|
||||
# self.set_default_view([2, 8, 2], [2, 2, 4])
|
||||
|
||||
# Connect slots
|
||||
@@ -175,29 +271,29 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
self.device_table_view.selected_devices,
|
||||
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
),
|
||||
(
|
||||
self.available_devices.selected_devices,
|
||||
(self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
),
|
||||
# (
|
||||
# self.available_devices.selected_devices,
|
||||
# (self.dm_config_view.on_select_config, self.dm_docs_view.on_select_config),
|
||||
# ),
|
||||
(
|
||||
self.ophyd_test_view.device_validated,
|
||||
(self.device_table_view.update_device_validation,),
|
||||
),
|
||||
(
|
||||
self.device_table_view.device_configs_changed,
|
||||
(
|
||||
self.ophyd_test_view.change_device_configs,
|
||||
self.available_devices.mark_devices_used,
|
||||
),
|
||||
),
|
||||
(
|
||||
self.available_devices.add_selected_devices,
|
||||
(self.device_table_view.add_device_configs,),
|
||||
),
|
||||
(
|
||||
self.available_devices.del_selected_devices,
|
||||
(self.device_table_view.remove_device_configs,),
|
||||
),
|
||||
# (
|
||||
# self.device_table_view.device_configs_changed,
|
||||
# (
|
||||
# self.ophyd_test_view.change_device_configs,
|
||||
# self.available_devices.mark_devices_used,
|
||||
# ),
|
||||
# ),
|
||||
# (
|
||||
# self.available_devices.add_selected_devices,
|
||||
# (self.device_table_view.add_device_configs,),
|
||||
# ),
|
||||
# (
|
||||
# self.available_devices.del_selected_devices,
|
||||
# (self.device_table_view.remove_device_configs,),
|
||||
# ),
|
||||
]:
|
||||
for slot in slots:
|
||||
signal.connect(slot)
|
||||
@@ -217,7 +313,9 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
# Create IO bundle
|
||||
io_bundle = ToolbarBundle("IO", self.toolbar.components)
|
||||
|
||||
# Load from disk
|
||||
load = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_open",
|
||||
parent=self,
|
||||
tooltip="Load configuration file from disk",
|
||||
@@ -229,6 +327,7 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Add safe to disk
|
||||
safe_to_disk = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="file_save",
|
||||
parent=self,
|
||||
tooltip="Save config to disk",
|
||||
@@ -240,10 +339,11 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Add load config from redis
|
||||
load_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cached",
|
||||
parent=self,
|
||||
tooltip="Load current config from Redis",
|
||||
label_text="Reload Config",
|
||||
label_text="Get Current Config",
|
||||
)
|
||||
load_redis.action.triggered.connect(self._load_redis_action)
|
||||
self.toolbar.components.add_safe("load_redis", load_redis)
|
||||
@@ -251,11 +351,13 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Update config action
|
||||
update_config_redis = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="cloud_upload",
|
||||
parent=self,
|
||||
tooltip="Update current config in Redis",
|
||||
label_text="Update Config",
|
||||
)
|
||||
update_config_redis.action.setEnabled(False)
|
||||
update_config_redis.action.triggered.connect(self._update_redis_action)
|
||||
self.toolbar.components.add_safe("update_config_redis", update_config_redis)
|
||||
io_bundle.add_action("update_config_redis")
|
||||
@@ -270,6 +372,7 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Reset composed view
|
||||
reset_composed = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="delete_sweep",
|
||||
parent=self,
|
||||
tooltip="Reset current composed config view",
|
||||
@@ -281,7 +384,11 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Add device
|
||||
add_device = MaterialIconAction(
|
||||
icon_name="add", parent=self, tooltip="Add new device", label_text="Add Device"
|
||||
text_position="under",
|
||||
icon_name="add",
|
||||
parent=self,
|
||||
tooltip="Add new device",
|
||||
label_text="Add Device",
|
||||
)
|
||||
add_device.action.triggered.connect(self._add_device_action)
|
||||
self.toolbar.components.add_safe("add_device", add_device)
|
||||
@@ -289,7 +396,11 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Remove device
|
||||
remove_device = MaterialIconAction(
|
||||
icon_name="remove", parent=self, tooltip="Remove device", label_text="Remove Device"
|
||||
text_position="under",
|
||||
icon_name="remove",
|
||||
parent=self,
|
||||
tooltip="Remove device",
|
||||
label_text="Remove Device",
|
||||
)
|
||||
remove_device.action.triggered.connect(self._remove_device_action)
|
||||
self.toolbar.components.add_safe("remove_device", remove_device)
|
||||
@@ -297,10 +408,11 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
|
||||
# Rerun validation
|
||||
rerun_validation = MaterialIconAction(
|
||||
text_position="under",
|
||||
icon_name="checklist",
|
||||
parent=self,
|
||||
tooltip="Run device validation with 'connect' on selected devices",
|
||||
label_text="Rerun Validation",
|
||||
label_text="Validate Connection",
|
||||
)
|
||||
rerun_validation.action.triggered.connect(self._rerun_validation_action)
|
||||
self.toolbar.components.add_safe("rerun_validation", rerun_validation)
|
||||
@@ -346,15 +458,26 @@ class DeviceManagerView(BECWidget, QWidget):
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, caption="Select Config File", dir=start_dir
|
||||
)
|
||||
if file_path:
|
||||
try:
|
||||
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
self.device_table_view.set_device_config(
|
||||
config
|
||||
) # TODO ADD QDialog with 'replace', 'add' & 'cancel'
|
||||
self._load_config_from_file(file_path)
|
||||
|
||||
def _load_config_from_file(self, file_path: str):
|
||||
"""
|
||||
Load device config from a given file path and update the device table view.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the configuration file.
|
||||
"""
|
||||
try:
|
||||
config = [{"name": k, **v} for k, v in yaml_load(file_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config from file {file_path}. Error: {e}")
|
||||
return
|
||||
dialog = ConfigChoiceDialog(self)
|
||||
if dialog.exec():
|
||||
if dialog.result() == ConfigChoiceDialog.REPLACE:
|
||||
self.device_table_view.set_device_config(config)
|
||||
elif dialog.result() == ConfigChoiceDialog.ADD:
|
||||
self.device_table_view.add_device_configs(config)
|
||||
|
||||
# TODO would we ever like to add the current config to an existing composition
|
||||
@SafeSlot()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from qtpy.QtCore import QEventLoop, Qt, QTimer
|
||||
from qtpy.QtCore import QEventLoop
|
||||
from qtpy.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
@@ -11,7 +9,6 @@ from qtpy.QtWidgets import (
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QStackedLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -23,42 +20,6 @@ from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox im
|
||||
from bec_widgets.widgets.plots.waveform.waveform import Waveform
|
||||
|
||||
|
||||
def set_splitter_weights(splitter: QSplitter, weights: List[float]) -> None:
|
||||
"""
|
||||
Apply initial sizes to a splitter using weight ratios, e.g. [1,3,2,1].
|
||||
Works for horizontal or vertical splitters and sets matching stretch factors.
|
||||
"""
|
||||
|
||||
def apply():
|
||||
n = splitter.count()
|
||||
if n == 0:
|
||||
return
|
||||
w = list(weights[:n]) + [1] * max(0, n - len(weights))
|
||||
w = [max(0.0, float(x)) for x in w]
|
||||
tot_w = sum(w)
|
||||
if tot_w <= 0:
|
||||
w = [1.0] * n
|
||||
tot_w = float(n)
|
||||
total_px = (
|
||||
splitter.width()
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal
|
||||
else splitter.height()
|
||||
)
|
||||
if total_px < 2:
|
||||
QTimer.singleShot(0, apply)
|
||||
return
|
||||
sizes = [max(1, int(total_px * (wi / tot_w))) for wi in w]
|
||||
diff = total_px - sum(sizes)
|
||||
if diff != 0:
|
||||
idx = max(range(n), key=lambda i: w[i])
|
||||
sizes[idx] = max(1, sizes[idx] + diff)
|
||||
splitter.setSizes(sizes)
|
||||
for i, wi in enumerate(w):
|
||||
splitter.setStretchFactor(i, max(1, int(round(wi * 100))))
|
||||
|
||||
QTimer.singleShot(0, apply)
|
||||
|
||||
|
||||
class ViewBase(QWidget):
|
||||
"""Wrapper for a content widget used inside the main app's stacked view.
|
||||
|
||||
@@ -115,68 +76,6 @@ class ViewBase(QWidget):
|
||||
"""
|
||||
return True
|
||||
|
||||
####### Default view has to be done with setting up splitters ########
|
||||
def set_default_view(self, horizontal_weights: list, vertical_weights: list):
|
||||
"""Apply initial weights to every horizontal and vertical splitter.
|
||||
|
||||
Examples:
|
||||
horizontal_weights = [1, 3, 2, 1]
|
||||
vertical_weights = [3, 7] # top:bottom = 30:70
|
||||
"""
|
||||
splitters_h = []
|
||||
splitters_v = []
|
||||
for splitter in self.findChildren(QSplitter):
|
||||
if splitter.orientation() == Qt.Orientation.Horizontal:
|
||||
splitters_h.append(splitter)
|
||||
elif splitter.orientation() == Qt.Orientation.Vertical:
|
||||
splitters_v.append(splitter)
|
||||
|
||||
def apply_all():
|
||||
for s in splitters_h:
|
||||
set_splitter_weights(s, horizontal_weights)
|
||||
for s in splitters_v:
|
||||
set_splitter_weights(s, vertical_weights)
|
||||
|
||||
QTimer.singleShot(0, apply_all)
|
||||
|
||||
def set_stretch(self, *, horizontal=None, vertical=None):
|
||||
"""Update splitter weights and re-apply to all splitters.
|
||||
|
||||
Accepts either a list/tuple of weights (e.g., [1,3,2,1]) or a role dict
|
||||
for convenience: horizontal roles = {"left","center","right"},
|
||||
vertical roles = {"top","bottom"}.
|
||||
"""
|
||||
|
||||
def _coerce_h(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [
|
||||
float(x.get("left", 1)),
|
||||
float(x.get("center", x.get("middle", 1))),
|
||||
float(x.get("right", 1)),
|
||||
]
|
||||
return None
|
||||
|
||||
def _coerce_v(x):
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, (list, tuple)):
|
||||
return list(map(float, x))
|
||||
if isinstance(x, dict):
|
||||
return [float(x.get("top", 1)), float(x.get("bottom", 1))]
|
||||
return None
|
||||
|
||||
h = _coerce_h(horizontal)
|
||||
v = _coerce_v(vertical)
|
||||
if h is None:
|
||||
h = [1, 1, 1]
|
||||
if v is None:
|
||||
v = [1, 1]
|
||||
self.set_default_view(h, v)
|
||||
|
||||
|
||||
####################################################################################################
|
||||
# Example views for demonstration/testing purposes
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.examples.developer_view.developer_widget import DeveloperWidget
|
||||
|
||||
|
||||
class DeveloperView(ViewBase):
|
||||
"""
|
||||
A view for users to write scripts and macros and execute them within the application.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
content: QWidget | None = None,
|
||||
*,
|
||||
id: str | None = None,
|
||||
title: str | None = None,
|
||||
):
|
||||
super().__init__(parent=parent, content=content, id=id, title=title)
|
||||
self.developer_widget = DeveloperWidget(parent=self)
|
||||
self.set_content(self.developer_widget)
|
||||
|
||||
# Apply stretch after the layout is done
|
||||
self.set_default_view([2, 5, 3], [7, 3])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
developer_view = DeveloperView()
|
||||
_app.add_view(
|
||||
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
|
||||
)
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,347 +0,0 @@
|
||||
import re
|
||||
|
||||
import markdown
|
||||
import PySide6QtAds as QtAds
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.script_executor import upload_script
|
||||
from bec_qthemes import material_icon
|
||||
from PySide6QtAds import CDockManager, CDockWidget
|
||||
from qtpy.QtGui import QKeySequence, QShortcut
|
||||
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets 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.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
|
||||
from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock
|
||||
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
|
||||
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
|
||||
|
||||
|
||||
def markdown_to_html(md_text: str) -> str:
|
||||
"""Convert Markdown with syntax highlighting to HTML (Qt-compatible)."""
|
||||
|
||||
# Preprocess: convert consecutive >>> lines to Python code blocks
|
||||
def replace_python_examples(match):
|
||||
indent = match.group(1)
|
||||
examples = match.group(2)
|
||||
# Remove >>> prefix and clean up the code
|
||||
lines = []
|
||||
for line in examples.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith(">>> "):
|
||||
lines.append(line[4:]) # Remove '>>> '
|
||||
elif line.startswith(">>>"):
|
||||
lines.append(line[3:]) # Remove '>>>'
|
||||
code = "\n".join(lines)
|
||||
|
||||
return f"{indent}```python\n{indent}{code}\n{indent}```"
|
||||
|
||||
# Match one or more consecutive >>> lines (with same indentation)
|
||||
pattern = r"^(\s*)((?:>>> .+(?:\n|$))+)"
|
||||
md_text = re.sub(pattern, replace_python_examples, md_text, flags=re.MULTILINE)
|
||||
|
||||
extensions = ["fenced_code", "codehilite", "tables", "sane_lists"]
|
||||
html = markdown.markdown(
|
||||
md_text,
|
||||
extensions=extensions,
|
||||
extension_configs={
|
||||
"codehilite": {"linenums": False, "guess_lang": False, "noclasses": True}
|
||||
},
|
||||
output_format="html",
|
||||
)
|
||||
|
||||
# Remove hardcoded background colors that conflict with themes
|
||||
html = re.sub(r'style="background: #[^"]*"', 'style="background: transparent"', html)
|
||||
html = re.sub(r"background: #[^;]*;", "", html)
|
||||
|
||||
# Add CSS to force code blocks to wrap
|
||||
css = """
|
||||
<style>
|
||||
pre, code {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
.codehilite pre {
|
||||
white-space: pre-wrap !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
return css + html
|
||||
|
||||
|
||||
class DeveloperWidget(BECWidget, QWidget):
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
self.toolbar = ModularToolBar(self)
|
||||
self.init_developer_toolbar()
|
||||
self._root_layout.addWidget(self.toolbar)
|
||||
|
||||
self.dock_manager = CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
|
||||
# Initialize the widgets
|
||||
self.explorer = IDEExplorer(self)
|
||||
self.console = WebConsole(self)
|
||||
self.terminal = WebConsole(self, startup_cmd="")
|
||||
self.monaco = MonacoDock(self)
|
||||
self.monaco.save_enabled.connect(self._on_save_enabled_update)
|
||||
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
|
||||
self.signature_help = QTextEdit(self)
|
||||
self.signature_help.setAcceptRichText(True)
|
||||
self.signature_help.setReadOnly(True)
|
||||
self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
|
||||
opt = self.signature_help.document().defaultTextOption()
|
||||
opt.setWrapMode(opt.WrapMode.WrapAnywhere)
|
||||
self.signature_help.document().setDefaultTextOption(opt)
|
||||
self.monaco.signature_help.connect(
|
||||
lambda text: self.signature_help.setHtml(markdown_to_html(text))
|
||||
)
|
||||
|
||||
# Create the dock widgets
|
||||
self.explorer_dock = QtAds.CDockWidget("Explorer", self)
|
||||
self.explorer_dock.setWidget(self.explorer)
|
||||
|
||||
self.console_dock = QtAds.CDockWidget("Console", self)
|
||||
self.console_dock.setWidget(self.console)
|
||||
|
||||
self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self)
|
||||
self.monaco_dock.setWidget(self.monaco)
|
||||
|
||||
self.terminal_dock = QtAds.CDockWidget("Terminal", self)
|
||||
self.terminal_dock.setWidget(self.terminal)
|
||||
|
||||
# Monaco will be central widget
|
||||
self.dock_manager.setCentralWidget(self.monaco_dock)
|
||||
|
||||
# Add the dock widgets to the dock manager
|
||||
area_bottom = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom)
|
||||
|
||||
area_left = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock
|
||||
)
|
||||
area_left.titleBar().setVisible(False)
|
||||
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
|
||||
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
|
||||
dock.setFeature(CDockWidget.DockWidgetClosable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetMovable, False)
|
||||
|
||||
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
|
||||
self.plotting_ads_dock.setWidget(self.plotting_ads)
|
||||
|
||||
self.signature_dock = QtAds.CDockWidget("Signature Help", self)
|
||||
self.signature_dock.setWidget(self.signature_help)
|
||||
|
||||
area_right = self.dock_manager.addDockWidget(
|
||||
QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock
|
||||
)
|
||||
self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right)
|
||||
|
||||
# Connect editor signals
|
||||
self.explorer.file_open_requested.connect(self._open_new_file)
|
||||
self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file)
|
||||
|
||||
self.toolbar.show_bundles(["save", "execution", "settings"])
|
||||
|
||||
def init_developer_toolbar(self):
|
||||
"""Initialize the developer toolbar with necessary actions and widgets."""
|
||||
save_button = MaterialIconAction(
|
||||
icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self
|
||||
)
|
||||
save_button.action.triggered.connect(self.on_save)
|
||||
self.toolbar.components.add_safe("save", save_button)
|
||||
|
||||
save_as_button = MaterialIconAction(
|
||||
icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self
|
||||
)
|
||||
self.toolbar.components.add_safe("save_as", save_as_button)
|
||||
|
||||
save_bundle = ToolbarBundle("save", self.toolbar.components)
|
||||
save_bundle.add_action("save")
|
||||
save_bundle.add_action("save_as")
|
||||
self.toolbar.add_bundle(save_bundle)
|
||||
|
||||
run_action = MaterialIconAction(
|
||||
icon_name="play_arrow",
|
||||
tooltip="Run current file",
|
||||
label_text="Run",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
run_action.action.triggered.connect(self.on_execute)
|
||||
self.toolbar.components.add_safe("run", run_action)
|
||||
|
||||
stop_action = MaterialIconAction(
|
||||
icon_name="stop",
|
||||
tooltip="Stop current execution",
|
||||
label_text="Stop",
|
||||
filled=True,
|
||||
parent=self,
|
||||
)
|
||||
stop_action.action.triggered.connect(self.on_stop)
|
||||
self.toolbar.components.add_safe("stop", stop_action)
|
||||
|
||||
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
|
||||
execution_bundle.add_action("run")
|
||||
execution_bundle.add_action("stop")
|
||||
self.toolbar.add_bundle(execution_bundle)
|
||||
|
||||
vim_action = MaterialIconAction(
|
||||
icon_name="vim",
|
||||
tooltip="Toggle Vim Mode",
|
||||
label_text="Vim",
|
||||
filled=True,
|
||||
parent=self,
|
||||
checkable=True,
|
||||
)
|
||||
self.toolbar.components.add_safe("vim", vim_action)
|
||||
vim_action.action.triggered.connect(self.on_vim_triggered)
|
||||
|
||||
settings_bundle = ToolbarBundle("settings", self.toolbar.components)
|
||||
settings_bundle.add_action("vim")
|
||||
self.toolbar.add_bundle(settings_bundle)
|
||||
|
||||
save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
|
||||
save_shortcut.activated.connect(self.on_save)
|
||||
save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self)
|
||||
save_as_shortcut.activated.connect(self.on_save_as)
|
||||
|
||||
def _open_new_file(self, file_name: str, scope: str):
|
||||
self.monaco.open_file(file_name, scope)
|
||||
|
||||
# Set read-only mode for shared files
|
||||
if "shared" in scope:
|
||||
self.monaco.set_file_readonly(file_name, True)
|
||||
|
||||
# Add appropriate icon based on file type
|
||||
if "script" in scope:
|
||||
# Use script icon for script files
|
||||
icon = material_icon("script", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
elif "macro" in scope:
|
||||
# Use function icon for macro files
|
||||
icon = material_icon("function", size=(24, 24))
|
||||
self.monaco.set_file_icon(file_name, icon)
|
||||
|
||||
@SafeSlot()
|
||||
def on_save(self):
|
||||
self.monaco.save_file()
|
||||
|
||||
@SafeSlot()
|
||||
def on_save_as(self):
|
||||
self.monaco.save_file(force_save_as=True)
|
||||
|
||||
@SafeSlot()
|
||||
def on_vim_triggered(self):
|
||||
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _on_save_enabled_update(self, enabled: bool):
|
||||
self.toolbar.components.get_action("save").action.setEnabled(enabled)
|
||||
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
|
||||
|
||||
@SafeSlot()
|
||||
def on_execute(self):
|
||||
self.script_editor_tab = self.monaco.last_focused_editor
|
||||
if not self.script_editor_tab:
|
||||
return
|
||||
self.current_script_id = upload_script(
|
||||
self.client.connector, self.script_editor_tab.widget().get_text()
|
||||
)
|
||||
self.console.write(f'bec._run_script("{self.current_script_id}")')
|
||||
print(f"Uploaded script with ID: {self.current_script_id}")
|
||||
|
||||
@SafeSlot()
|
||||
def on_stop(self):
|
||||
print("Stopping execution...")
|
||||
|
||||
@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.script_editor_tab.widget().clear_highlighted_lines()
|
||||
return
|
||||
line_number = current_lines[0]
|
||||
self.script_editor_tab.widget().clear_highlighted_lines()
|
||||
self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.applications.main_app import BECMainApp
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
|
||||
_app = BECMainApp()
|
||||
screen = app.primaryScreen()
|
||||
screen_geometry = screen.availableGeometry()
|
||||
screen_width = screen_geometry.width()
|
||||
screen_height = screen_geometry.height()
|
||||
# 70% of screen height, keep 16:9 ratio
|
||||
height = int(screen_height * 0.9)
|
||||
width = int(height * (16 / 9))
|
||||
|
||||
# If width exceeds screen width, scale down
|
||||
if width > screen_width * 0.9:
|
||||
width = int(screen_width * 0.9)
|
||||
height = int(width / (16 / 9))
|
||||
|
||||
_app.resize(width, height)
|
||||
developer_view = DeveloperView()
|
||||
_app.add_view(
|
||||
icon="code_blocks", title="IDE", widget=developer_view, id="developer_view", exclusive=True
|
||||
)
|
||||
_app.show()
|
||||
# developer_view.show()
|
||||
# developer_view.setWindowTitle("Developer View")
|
||||
# developer_view.resize(1920, 1080)
|
||||
# developer_view.set_stretch(horizontal=[1, 3, 2], vertical=[5, 5]) #can be set during runtime
|
||||
sys.exit(app.exec_())
|
||||
@@ -185,6 +185,14 @@ class BECWidget(BECConnector):
|
||||
except Exception:
|
||||
logger.warning(f"Failed to apply theme {theme} to {self}")
|
||||
|
||||
def get_help_md(self) -> str:
|
||||
"""
|
||||
Method to override in subclasses to provide help text in markdown format.
|
||||
|
||||
Returns:
|
||||
str: The help text in markdown format.
|
||||
"""
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(str)
|
||||
@rpc_timeout(None)
|
||||
|
||||
246
bec_widgets/utils/help_inspector/help_inspector.py
Normal file
246
bec_widgets/utils/help_inspector/help_inspector.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Module providing a simple help inspector tool for QtWidgets."""
|
||||
|
||||
from functools import partial
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import AccentColors, get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.widget_io import WidgetHierarchy
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class HelpInspector(BECWidget, QtWidgets.QWidget):
|
||||
"""
|
||||
A help inspector widget that allows to inspect other widgets in the application.
|
||||
Per default, it emits signals with the docstring, tooltip and bec help text of the inspected widget.
|
||||
The method "get_help_md" is called on the widget which is added to the BECWidget base class.
|
||||
It should return a string with a help text, ideally in proper format to be displayed (i.e. markdown).
|
||||
The inspector also allows to register custom callback that are called with the inspected widget
|
||||
as argument. This may be useful in the future to hook up more callbacks with custom signals.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None): The parent widget of the help inspector.
|
||||
client: Optional client for BECWidget functionality.
|
||||
size (tuple[int, int]): Optional size of the icon for the help inspector.
|
||||
"""
|
||||
|
||||
widget_docstring = QtCore.Signal(str) # Emits docstring from QWidget
|
||||
widget_tooltip = QtCore.Signal(str) # Emits tooltip string from QWidget
|
||||
bec_widget_help = QtCore.Signal(str) # Emits md formatted help string from BECWidget class
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(client=client, parent=parent, theme_update=True)
|
||||
self._app = QtWidgets.QApplication.instance()
|
||||
layout = QtWidgets.QHBoxLayout(self) # type: ignore
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self._active = False
|
||||
self._init_ui()
|
||||
self._callbacks = {}
|
||||
# Register the default callbacks
|
||||
self._register_default_callbacks()
|
||||
# Connect the button toggle signal
|
||||
self._button.toggled.connect(self._toggle_mode)
|
||||
|
||||
def _init_ui(self):
|
||||
"""Init the UI components."""
|
||||
colors: AccentColors = get_accent_colors()
|
||||
self._button = QtWidgets.QToolButton(self.parent())
|
||||
self._button.setCheckable(True)
|
||||
|
||||
self._icon_checked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
|
||||
)
|
||||
self._icon_unchecked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
|
||||
)
|
||||
self._button.setText("Help Inspect Tool")
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
self._button.setToolTip("Click to enter Help Mode")
|
||||
self.layout().addWidget(self._button)
|
||||
|
||||
def apply_theme(self, theme: str) -> None:
|
||||
colors = get_accent_colors()
|
||||
self._icon_checked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=True
|
||||
)
|
||||
self._icon_unchecked = partial(
|
||||
material_icon, "help", size=(32, 32), color=colors.highlight, filled=False
|
||||
)
|
||||
if self._active:
|
||||
self._button.setIcon(self._icon_checked())
|
||||
else:
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _toggle_mode(self, enabled: bool):
|
||||
"""
|
||||
Toggle the help inspection mode.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether to enable or disable the help inspection mode.
|
||||
"""
|
||||
if self._app is None:
|
||||
self._app = QtWidgets.QApplication.instance()
|
||||
self._active = enabled
|
||||
if enabled:
|
||||
self._app.installEventFilter(self)
|
||||
self._button.setIcon(self._icon_checked())
|
||||
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.WhatsThisCursor)
|
||||
else:
|
||||
self._app.removeEventFilter(self)
|
||||
self._button.setIcon(self._icon_unchecked())
|
||||
self._button.setChecked(False)
|
||||
QtWidgets.QApplication.restoreOverrideCursor()
|
||||
|
||||
def eventFilter(self, obj: QtWidgets.QWidget, event: QtCore.QEvent) -> bool:
|
||||
"""
|
||||
Filter events to capture Key_Escape event, and mouse clicks
|
||||
if event filter is active. Any click event on a widget is suppressed, if
|
||||
the Inspector is active, and the registered callbacks are called with
|
||||
the clicked widget as argument.
|
||||
|
||||
Args:
|
||||
obj (QObject): The object that received the event.
|
||||
event (QEvent): The event to filter.
|
||||
"""
|
||||
# If not active, return immediately
|
||||
if not self._active:
|
||||
return super().eventFilter(obj, event)
|
||||
# If active, handle escape key
|
||||
if event.type() == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Escape:
|
||||
self._toggle_mode(False)
|
||||
return super().eventFilter(obj, event)
|
||||
# If active, and left mouse button pressed, handle click
|
||||
if event.type() == QtCore.QEvent.MouseButtonPress:
|
||||
if event.button() == QtCore.Qt.LeftButton:
|
||||
widget = self._app.widgetAt(event.globalPos())
|
||||
if widget is None:
|
||||
return super().eventFilter(obj, event)
|
||||
# Get BECWidget ancestor
|
||||
# TODO check what happens if the HELP Inspector itself is embedded in another BECWidget
|
||||
# I suppose we would like to get the first ancestor that is a BECWidget, not the topmost one
|
||||
widget = WidgetHierarchy._get_becwidget_ancestor(widget)
|
||||
if widget:
|
||||
if widget is self:
|
||||
self._toggle_mode(False)
|
||||
return True
|
||||
for cb in self._callbacks.values():
|
||||
try:
|
||||
cb(widget)
|
||||
except Exception as e:
|
||||
logger.error(f"Error occurred in callback {cb}: {e}")
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def register_callback(self, callback: Callable[[QtWidgets.QWidget], None]) -> str:
|
||||
"""
|
||||
Register a callback to be called when a widget is inspected.
|
||||
The callback should be callable with the following signature:
|
||||
callback(widget: QWidget) -> None
|
||||
|
||||
Args:
|
||||
callback (Callable[[QWidget], None]): The callback function to register.
|
||||
Returns:
|
||||
str: A unique ID for the registered callback.
|
||||
"""
|
||||
cb_id = str(uuid4())
|
||||
self._callbacks[cb_id] = callback
|
||||
return cb_id
|
||||
|
||||
def unregister_callback(self, cb_id: str):
|
||||
"""Unregister a previously registered callback."""
|
||||
self._callbacks.pop(cb_id, None)
|
||||
|
||||
def _register_default_callbacks(self):
|
||||
"""Default behavior: publish tooltip, docstring, bec_help"""
|
||||
|
||||
def cb_doc(widget: QtWidgets.QWidget):
|
||||
docstring = widget.__doc__ or "No documentation available."
|
||||
self.widget_docstring.emit(docstring)
|
||||
|
||||
def cb_help(widget: QtWidgets.QWidget):
|
||||
tooltip = widget.toolTip() or "No tooltip available."
|
||||
self.widget_tooltip.emit(tooltip)
|
||||
|
||||
def cb_bec_help(widget: QtWidgets.QWidget):
|
||||
help_text = None
|
||||
if hasattr(widget, "get_help_md") and callable(widget.get_help_md):
|
||||
try:
|
||||
help_text = widget.get_help_md()
|
||||
except Exception as e:
|
||||
logger.debug(f"Error retrieving help text from {widget}: {e}")
|
||||
if help_text is None:
|
||||
help_text = widget.toolTip() or "No help available."
|
||||
if not isinstance(help_text, str):
|
||||
logger.error(
|
||||
f"Help text from {widget.__class__} is not a string: {type(help_text)}"
|
||||
)
|
||||
help_text = str(help_text)
|
||||
self.bec_widget_help.emit(help_text)
|
||||
|
||||
self.register_callback(cb_doc)
|
||||
self.register_callback(cb_help)
|
||||
self.register_callback(cb_bec_help)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from bec_qthemes import apply_theme
|
||||
|
||||
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
|
||||
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
|
||||
main_window = QtWidgets.QMainWindow()
|
||||
apply_theme("dark")
|
||||
main_window.setWindowTitle("Help Inspector Test")
|
||||
|
||||
central_widget = QtWidgets.QWidget()
|
||||
main_layout = QtWidgets.QVBoxLayout(central_widget)
|
||||
dark_mode_button = DarkModeButton(parent=main_window)
|
||||
main_layout.addWidget(dark_mode_button)
|
||||
|
||||
help_inspector = HelpInspector()
|
||||
main_layout.addWidget(help_inspector)
|
||||
|
||||
test_button = QtWidgets.QPushButton("Test Button")
|
||||
test_button.setToolTip("This is a test button.")
|
||||
test_line_edit = QtWidgets.QLineEdit()
|
||||
test_line_edit.setToolTip("This is a test line edit.")
|
||||
test_label = QtWidgets.QLabel("Test Label")
|
||||
test_label.setToolTip("")
|
||||
box = PositionerBox()
|
||||
|
||||
layout_1 = QtWidgets.QHBoxLayout()
|
||||
layout_1.addWidget(test_button)
|
||||
layout_1.addWidget(test_line_edit)
|
||||
layout_1.addWidget(test_label)
|
||||
layout_1.addWidget(box)
|
||||
main_layout.addLayout(layout_1)
|
||||
|
||||
doc_label = QtWidgets.QLabel("Docstring will appear here.")
|
||||
tool_tip_label = QtWidgets.QLabel("Tooltip will appear here.")
|
||||
bec_help_label = QtWidgets.QLabel("BEC Help text will appear here.")
|
||||
main_layout.addWidget(doc_label)
|
||||
main_layout.addWidget(tool_tip_label)
|
||||
main_layout.addWidget(bec_help_label)
|
||||
|
||||
help_inspector.widget_tooltip.connect(tool_tip_label.setText)
|
||||
help_inspector.widget_docstring.connect(doc_label.setText)
|
||||
help_inspector.bec_widget_help.connect(bec_help_label.setText)
|
||||
|
||||
main_window.setCentralWidget(central_widget)
|
||||
main_window.resize(400, 200)
|
||||
main_window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -45,10 +45,10 @@ def create_action_with_text(toolbar_action, toolbar: QToolBar):
|
||||
btn = QToolButton(parent=toolbar)
|
||||
btn.setDefaultAction(toolbar_action.action)
|
||||
btn.setAutoRaise(True)
|
||||
if toolbar_action.text_position == "beside":
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
else:
|
||||
if toolbar_action.text_position == "under":
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
|
||||
else:
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
btn.setText(toolbar_action.label_text)
|
||||
toolbar.addWidget(btn)
|
||||
|
||||
|
||||
@@ -24,14 +24,7 @@ class CollapsibleSection(QWidget):
|
||||
|
||||
section_reorder_requested = Signal(str, str) # (source_title, target_title)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
title="",
|
||||
indentation=10,
|
||||
show_add_button=False,
|
||||
tooltip: str | None = None,
|
||||
):
|
||||
def __init__(self, parent=None, title="", indentation=10, show_add_button=False):
|
||||
super().__init__(parent=parent)
|
||||
self.title = title
|
||||
self.content_widget = None
|
||||
@@ -57,8 +50,6 @@ class CollapsibleSection(QWidget):
|
||||
self.header_button.mouseMoveEvent = self._header_mouse_move_event
|
||||
self.header_button.dragEnterEvent = self._header_drag_enter_event
|
||||
self.header_button.dropEvent = self._header_drop_event
|
||||
if tooltip:
|
||||
self.header_button.setToolTip(tooltip)
|
||||
|
||||
self.drag_start_position = None
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ class Explorer(BECWidget, QWidget):
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Main layout
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
import ast
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QModelIndex, QRect, Qt, Signal
|
||||
from qtpy.QtGui import QPainter, QStandardItem, QStandardItemModel
|
||||
from qtpy.QtWidgets import QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class MacroItemDelegate(QStyledItemDelegate):
|
||||
"""Custom delegate to show action buttons on hover for macro functions"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.hovered_index = QModelIndex()
|
||||
self.macro_actions: list[Any] = []
|
||||
self.button_rects: list[QRect] = []
|
||||
self.current_macro_info = {}
|
||||
|
||||
def add_macro_action(self, action: Any) -> None:
|
||||
"""Add an action for macro functions"""
|
||||
self.macro_actions.append(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions"""
|
||||
self.macro_actions.clear()
|
||||
|
||||
def paint(self, painter, option, index):
|
||||
"""Paint the item with action buttons on hover"""
|
||||
# Paint the default item
|
||||
super().paint(painter, option, index)
|
||||
|
||||
# Early return if not hovering over this item
|
||||
if index != self.hovered_index:
|
||||
return
|
||||
|
||||
# Only show actions for macro functions (not directories)
|
||||
item = index.model().itemFromIndex(index)
|
||||
if not item or not item.data(Qt.ItemDataRole.UserRole):
|
||||
return
|
||||
|
||||
macro_info = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(macro_info, dict) or "function_name" not in macro_info:
|
||||
return
|
||||
|
||||
self.current_macro_info = macro_info
|
||||
|
||||
if self.macro_actions:
|
||||
self._draw_action_buttons(painter, option, self.macro_actions)
|
||||
|
||||
def _draw_action_buttons(self, painter, option, actions: list[Any]):
|
||||
"""Draw action buttons on the right side"""
|
||||
button_size = 18
|
||||
margin = 4
|
||||
spacing = 2
|
||||
|
||||
# Calculate total width needed for all buttons
|
||||
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
|
||||
|
||||
# Clear previous button rects and create new ones
|
||||
self.button_rects.clear()
|
||||
|
||||
# Calculate starting position (right side of the item)
|
||||
start_x = option.rect.right() - total_width - margin
|
||||
current_x = start_x
|
||||
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
# Get theme colors for better integration
|
||||
palette = get_theme_palette()
|
||||
button_bg = palette.button().color()
|
||||
button_bg.setAlpha(150) # Semi-transparent
|
||||
|
||||
for action in actions:
|
||||
if not action.isVisible():
|
||||
continue
|
||||
|
||||
# Calculate button position
|
||||
button_rect = QRect(
|
||||
current_x,
|
||||
option.rect.top() + (option.rect.height() - button_size) // 2,
|
||||
button_size,
|
||||
button_size,
|
||||
)
|
||||
self.button_rects.append(button_rect)
|
||||
|
||||
# Draw button background
|
||||
painter.setBrush(button_bg)
|
||||
painter.setPen(palette.mid().color())
|
||||
painter.drawRoundedRect(button_rect, 3, 3)
|
||||
|
||||
# Draw action icon
|
||||
icon = action.icon()
|
||||
if not icon.isNull():
|
||||
icon_rect = button_rect.adjusted(2, 2, -2, -2)
|
||||
icon.paint(painter, icon_rect)
|
||||
|
||||
# Move to next button position
|
||||
current_x += button_size + spacing
|
||||
|
||||
painter.restore()
|
||||
|
||||
def editorEvent(self, event, model, option, index):
|
||||
"""Handle mouse events for action buttons"""
|
||||
# Early return if not a left click
|
||||
if not (
|
||||
event.type() == event.Type.MouseButtonPress
|
||||
and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
# Check which button was clicked
|
||||
visible_actions = [action for action in self.macro_actions if action.isVisible()]
|
||||
for i, button_rect in enumerate(self.button_rects):
|
||||
if button_rect.contains(event.pos()) and i < len(visible_actions):
|
||||
# Trigger the action
|
||||
visible_actions[i].trigger()
|
||||
return True
|
||||
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
def set_hovered_index(self, index):
|
||||
"""Set the currently hovered index"""
|
||||
self.hovered_index = index
|
||||
|
||||
|
||||
class MacroTreeWidget(QWidget):
|
||||
"""A tree widget that displays macro functions from Python files"""
|
||||
|
||||
macro_selected = Signal(str, str) # Function name, file path
|
||||
macro_open_requested = Signal(str, str) # Function name, file path
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Create layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Create tree view
|
||||
self.tree = QTreeView()
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setRootIsDecorated(True)
|
||||
|
||||
# Disable editing to prevent renaming on double-click
|
||||
self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers)
|
||||
|
||||
# Enable mouse tracking for hover effects
|
||||
self.tree.setMouseTracking(True)
|
||||
|
||||
# Create model for macro functions
|
||||
self.model = QStandardItemModel()
|
||||
self.tree.setModel(self.model)
|
||||
|
||||
# Create and set custom delegate
|
||||
self.delegate = MacroItemDelegate(self.tree)
|
||||
self.tree.setItemDelegate(self.delegate)
|
||||
|
||||
# Add default open button for macros
|
||||
action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self)
|
||||
action.action.triggered.connect(self._on_macro_open_requested)
|
||||
self.delegate.add_macro_action(action.action)
|
||||
|
||||
# Apply BEC styling
|
||||
self._apply_styling()
|
||||
|
||||
# Macro specific properties
|
||||
self.directory = None
|
||||
|
||||
# Connect signals
|
||||
self.tree.clicked.connect(self._on_item_clicked)
|
||||
self.tree.doubleClicked.connect(self._on_item_double_clicked)
|
||||
|
||||
# Install event filter for hover tracking
|
||||
self.tree.viewport().installEventFilter(self)
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(self.tree)
|
||||
|
||||
def _apply_styling(self):
|
||||
"""Apply styling to the tree widget"""
|
||||
# Get theme colors for subtle tree lines
|
||||
palette = get_theme_palette()
|
||||
subtle_line_color = palette.mid().color()
|
||||
subtle_line_color.setAlpha(80)
|
||||
|
||||
# Standard editable styling
|
||||
opacity_modifier = ""
|
||||
cursor_style = ""
|
||||
|
||||
# pylint: disable=f-string-without-interpolation
|
||||
tree_style = f"""
|
||||
QTreeView {{
|
||||
border: none;
|
||||
outline: 0;
|
||||
show-decoration-selected: 0;
|
||||
{opacity_modifier}
|
||||
{cursor_style}
|
||||
}}
|
||||
QTreeView::branch {{
|
||||
border-image: none;
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
QTreeView::item {{
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}}
|
||||
QTreeView::item:hover {{
|
||||
background: palette(midlight);
|
||||
border: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
text-decoration: none;
|
||||
}}
|
||||
QTreeView::item:selected {{
|
||||
background: palette(highlight);
|
||||
color: palette(highlighted-text);
|
||||
}}
|
||||
QTreeView::item:selected:hover {{
|
||||
background: palette(highlight);
|
||||
}}
|
||||
"""
|
||||
|
||||
self.tree.setStyleSheet(tree_style)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Handle mouse move events for hover tracking"""
|
||||
# Early return if not the tree viewport
|
||||
if obj != self.tree.viewport():
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.MouseMove:
|
||||
index = self.tree.indexAt(event.pos())
|
||||
if index.isValid():
|
||||
self.delegate.set_hovered_index(index)
|
||||
else:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == event.Type.Leave:
|
||||
self.delegate.set_hovered_index(QModelIndex())
|
||||
self.tree.viewport().update()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def set_directory(self, directory):
|
||||
"""Set the macros directory and scan for macro functions"""
|
||||
self.directory = directory
|
||||
|
||||
# Early return if directory doesn't exist
|
||||
if not directory or not os.path.exists(directory):
|
||||
return
|
||||
|
||||
self._scan_macro_functions()
|
||||
|
||||
def _create_file_item(self, py_file: Path) -> QStandardItem | None:
|
||||
"""Create a file item with its functions
|
||||
|
||||
Args:
|
||||
py_file: Path to the Python file
|
||||
|
||||
Returns:
|
||||
QStandardItem representing the file, or None if no functions found
|
||||
"""
|
||||
# Skip files starting with underscore
|
||||
if py_file.name.startswith("_"):
|
||||
return None
|
||||
|
||||
try:
|
||||
functions = self._extract_functions_from_file(py_file)
|
||||
if not functions:
|
||||
return None
|
||||
|
||||
# Create a file node
|
||||
file_item = QStandardItem(py_file.stem)
|
||||
file_item.setData({"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole)
|
||||
|
||||
# Add function nodes
|
||||
for func_name, func_info in functions.items():
|
||||
func_item = QStandardItem(func_name)
|
||||
func_data = {
|
||||
"function_name": func_name,
|
||||
"file_path": str(py_file),
|
||||
"line_number": func_info.get("line_number", 1),
|
||||
"type": "function",
|
||||
}
|
||||
func_item.setData(func_data, Qt.ItemDataRole.UserRole)
|
||||
file_item.appendRow(func_item)
|
||||
|
||||
return file_item
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse {py_file}: {e}")
|
||||
return None
|
||||
|
||||
def _scan_macro_functions(self):
|
||||
"""Scan the directory for Python files and extract macro functions"""
|
||||
self.model.clear()
|
||||
self.model.setHorizontalHeaderLabels(["Macros"])
|
||||
|
||||
if not self.directory or not os.path.exists(self.directory):
|
||||
return
|
||||
|
||||
# Get all Python files in the directory
|
||||
python_files = list(Path(self.directory).glob("*.py"))
|
||||
|
||||
for py_file in python_files:
|
||||
file_item = self._create_file_item(py_file)
|
||||
if file_item:
|
||||
self.model.appendRow(file_item)
|
||||
|
||||
self.tree.expandAll()
|
||||
|
||||
def _extract_functions_from_file(self, file_path: Path) -> dict:
|
||||
"""Extract function definitions from a Python file"""
|
||||
functions = {}
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse the AST
|
||||
tree = ast.parse(content)
|
||||
|
||||
# Only get top-level function definitions
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
functions[node.name] = {
|
||||
"line_number": node.lineno,
|
||||
"docstring": ast.get_docstring(node) or "",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse {file_path}: {e}")
|
||||
|
||||
return functions
|
||||
|
||||
def _on_item_clicked(self, index: QModelIndex):
|
||||
"""Handle item clicks"""
|
||||
item = self.model.itemFromIndex(index)
|
||||
if not item:
|
||||
return
|
||||
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not data:
|
||||
return
|
||||
|
||||
if data.get("type") == "function":
|
||||
function_name = data.get("function_name")
|
||||
file_path = data.get("file_path")
|
||||
if function_name and file_path:
|
||||
logger.info(f"Macro function selected: {function_name} in {file_path}")
|
||||
self.macro_selected.emit(function_name, file_path)
|
||||
|
||||
def _on_item_double_clicked(self, index: QModelIndex):
|
||||
"""Handle item double-clicks"""
|
||||
item = self.model.itemFromIndex(index)
|
||||
if not item:
|
||||
return
|
||||
|
||||
data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if not data:
|
||||
return
|
||||
|
||||
if data.get("type") == "function":
|
||||
function_name = data.get("function_name")
|
||||
file_path = data.get("file_path")
|
||||
if function_name and file_path:
|
||||
logger.info(
|
||||
f"Macro open requested via double-click: {function_name} in {file_path}"
|
||||
)
|
||||
self.macro_open_requested.emit(function_name, file_path)
|
||||
|
||||
def _on_macro_open_requested(self):
|
||||
"""Handle macro open action triggered"""
|
||||
logger.info("Macro open requested")
|
||||
# Early return if no hovered item
|
||||
if not self.delegate.hovered_index.isValid():
|
||||
return
|
||||
|
||||
macro_info = self.delegate.current_macro_info
|
||||
if not macro_info or macro_info.get("type") != "function":
|
||||
return
|
||||
|
||||
function_name = macro_info.get("function_name")
|
||||
file_path = macro_info.get("file_path")
|
||||
if function_name and file_path:
|
||||
self.macro_open_requested.emit(function_name, file_path)
|
||||
|
||||
def add_macro_action(self, action: Any) -> None:
|
||||
"""Add an action for macro items"""
|
||||
self.delegate.add_macro_action(action)
|
||||
|
||||
def clear_actions(self) -> None:
|
||||
"""Remove all actions from items"""
|
||||
self.delegate.clear_actions()
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the tree view"""
|
||||
if self.directory is None:
|
||||
return
|
||||
self._scan_macro_functions()
|
||||
|
||||
def refresh_file_item(self, file_path: str):
|
||||
"""Refresh a single file item by re-scanning its functions
|
||||
|
||||
Args:
|
||||
file_path: Path to the Python file to refresh
|
||||
"""
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
logger.warning(f"Cannot refresh file item: {file_path} does not exist")
|
||||
return
|
||||
|
||||
py_file = Path(file_path)
|
||||
|
||||
# Find existing file item in the model
|
||||
existing_item = None
|
||||
existing_row = -1
|
||||
for row in range(self.model.rowCount()):
|
||||
item = self.model.item(row)
|
||||
if not item or not item.data(Qt.ItemDataRole.UserRole):
|
||||
continue
|
||||
item_data = item.data(Qt.ItemDataRole.UserRole)
|
||||
if item_data.get("type") == "file" and item_data.get("file_path") == str(py_file):
|
||||
existing_item = item
|
||||
existing_row = row
|
||||
break
|
||||
|
||||
# Store expansion state if item exists
|
||||
was_expanded = existing_item and self.tree.isExpanded(existing_item.index())
|
||||
|
||||
# Remove existing item if found
|
||||
if existing_item and existing_row >= 0:
|
||||
self.model.removeRow(existing_row)
|
||||
|
||||
# Create new item using the helper method
|
||||
new_item = self._create_file_item(py_file)
|
||||
if new_item:
|
||||
# Insert at the same position or append if it was a new file
|
||||
insert_row = existing_row if existing_row >= 0 else self.model.rowCount()
|
||||
self.model.insertRow(insert_row, new_item)
|
||||
|
||||
# Restore expansion state
|
||||
if was_expanded:
|
||||
self.tree.expand(new_item.index())
|
||||
else:
|
||||
self.tree.expand(new_item.index())
|
||||
|
||||
def expand_all(self):
|
||||
"""Expand all items in the tree"""
|
||||
self.tree.expandAll()
|
||||
|
||||
def collapse_all(self):
|
||||
"""Collapse all items in the tree"""
|
||||
self.tree.collapseAll()
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
|
||||
from qtpy.QtGui import QPainter
|
||||
from qtpy.QtGui import QAction, QPainter
|
||||
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import get_theme_palette
|
||||
@@ -15,20 +15,19 @@ logger = bec_logger.logger
|
||||
class FileItemDelegate(QStyledItemDelegate):
|
||||
"""Custom delegate to show action buttons on hover"""
|
||||
|
||||
def __init__(self, tree_widget):
|
||||
super().__init__(tree_widget)
|
||||
self.setObjectName("file_item_delegate")
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.hovered_index = QModelIndex()
|
||||
self.file_actions = []
|
||||
self.dir_actions = []
|
||||
self.button_rects = []
|
||||
self.file_actions: list[QAction] = []
|
||||
self.dir_actions: list[QAction] = []
|
||||
self.button_rects: list[QRect] = []
|
||||
self.current_file_path = ""
|
||||
|
||||
def add_file_action(self, action) -> None:
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for files"""
|
||||
self.file_actions.append(action)
|
||||
|
||||
def add_dir_action(self, action) -> None:
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directories"""
|
||||
self.dir_actions.append(action)
|
||||
|
||||
@@ -68,7 +67,7 @@ class FileItemDelegate(QStyledItemDelegate):
|
||||
if actions:
|
||||
self._draw_action_buttons(painter, option, actions)
|
||||
|
||||
def _draw_action_buttons(self, painter, option, actions):
|
||||
def _draw_action_buttons(self, painter, option, actions: list[QAction]):
|
||||
"""Draw action buttons on the right side"""
|
||||
button_size = 18
|
||||
margin = 4
|
||||
@@ -230,18 +229,12 @@ class ScriptTreeWidget(QWidget):
|
||||
subtle_line_color = palette.mid().color()
|
||||
subtle_line_color.setAlpha(80)
|
||||
|
||||
# Standard editable styling
|
||||
opacity_modifier = ""
|
||||
cursor_style = ""
|
||||
|
||||
# pylint: disable=f-string-without-interpolation
|
||||
tree_style = f"""
|
||||
QTreeView {{
|
||||
border: none;
|
||||
outline: 0;
|
||||
show-decoration-selected: 0;
|
||||
{opacity_modifier}
|
||||
{cursor_style}
|
||||
}}
|
||||
QTreeView::branch {{
|
||||
border-image: none;
|
||||
@@ -364,11 +357,11 @@ class ScriptTreeWidget(QWidget):
|
||||
|
||||
self.file_open_requested.emit(file_path)
|
||||
|
||||
def add_file_action(self, action) -> None:
|
||||
def add_file_action(self, action: QAction) -> None:
|
||||
"""Add an action for file items"""
|
||||
self.delegate.add_file_action(action)
|
||||
|
||||
def add_dir_action(self, action) -> None:
|
||||
def add_dir_action(self, action: QAction) -> None:
|
||||
"""Add an action for directory items"""
|
||||
self.delegate.add_dir_action(action)
|
||||
|
||||
|
||||
@@ -6,3 +6,58 @@ MIME_DEVICE_CONFIG: Final[str] = "application/x-bec_device_config"
|
||||
# Custom user roles
|
||||
SORT_KEY_ROLE: Final[int] = 117
|
||||
CONFIG_DATA_ROLE: Final[int] = 118
|
||||
|
||||
# TODO keep in sync with header...
|
||||
HEADERS_HELP_MD: dict[str, str] = {
|
||||
"status": (
|
||||
"## Status"
|
||||
"\n"
|
||||
"The current status of the device. Can be one of the following values: \n"
|
||||
"### **LOADED** \n The device with the specified configuration is loaded in the current config.\n"
|
||||
"### **CONNECT_READY** \n The device config is valid and the connection has been validated. It has not yet been loaded to the current config.\n"
|
||||
"### **CONNECT_FAILED** \n The device config is valid, but the connection could not be established.\n"
|
||||
"### **VALID** \n The device config is valid, but the connection has not yet been validated.\n"
|
||||
"### **INVALID** \n The device config is invalid and can not be loaded to the current config.\n"
|
||||
),
|
||||
"name": ("## Name " "\n" "The name of the device."),
|
||||
"deviceClass": (
|
||||
"## Device Class"
|
||||
"\n"
|
||||
"The device class specifies the type of the device. It will be used to create the instance."
|
||||
),
|
||||
"readoutPriority": (
|
||||
"## Readout Priority"
|
||||
"\n"
|
||||
"The readout priority of the device. Can be one of the following values: \n"
|
||||
"### **monitored** \n The monitored priority is used for devices that are read out during the scan (i.e. at every step) and whose value may change during the scan.\n"
|
||||
"### **baseline** \n The baseline priority is used for devices that are read out at the beginning of the scan and whose value does not change during the scan.\n"
|
||||
"### **async** \n The async priority is used for devices that are asynchronous to the monitored devices, and send their data independently.\n"
|
||||
"### **continuous** \n The continuous priority is used for devices that are read out continuously during the scan.\n"
|
||||
"### **on_request** \n The on_request priority is used for devices that should not be read out during the scan, yet are configured to be read out manually.\n"
|
||||
),
|
||||
"deviceTags": (
|
||||
"## Device Tags"
|
||||
"\n"
|
||||
"A list of tags associated with the device. Tags can be used to group devices and filter them in the device manager."
|
||||
),
|
||||
"enabled": (
|
||||
"## Enabled"
|
||||
"\n"
|
||||
"Indicator whether the device is enabled or disabled. Disabled devices can not be used."
|
||||
),
|
||||
"readOnly": ("## Read Only" "\n" "Indicator that a device is read-only or can be modified."),
|
||||
"onFailure": (
|
||||
"## On Failure"
|
||||
"\n"
|
||||
"Specifies the behavior of the device in case of a failure. Can be one of the following values: \n"
|
||||
"### **buffer** \n The device readback will fall back to the last known value.\n"
|
||||
"### **retry** \n The device readback will be retried once, and raises an error if it fails again.\n"
|
||||
"### **raise** \n The device readback will raise immediately.\n"
|
||||
),
|
||||
"softwareTrigger": (
|
||||
"## Software Trigger"
|
||||
"\n"
|
||||
"Indicator whether the device receives a software trigger from BEC during a scan."
|
||||
),
|
||||
"description": ("## Description" "\n" "A short description of the device."),
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import textwrap
|
||||
from contextlib import contextmanager
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, Iterable, List
|
||||
from uuid import uuid4
|
||||
|
||||
from bec_lib.atlas_models import Device
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
@@ -21,7 +23,10 @@ from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.control.device_manager.components._util import SharedSelectionSignal
|
||||
from bec_widgets.widgets.control.device_manager.components.constants import MIME_DEVICE_CONFIG
|
||||
from bec_widgets.widgets.control.device_manager.components.constants import (
|
||||
HEADERS_HELP_MD,
|
||||
MIME_DEVICE_CONFIG,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_manager.components.dm_ophyd_test import ValidationStatus
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
@@ -75,6 +80,111 @@ class CustomDisplayDelegate(DictToolTipDelegate):
|
||||
painter.restore()
|
||||
|
||||
|
||||
class WrappingTextDelegate(CustomDisplayDelegate):
|
||||
"""A lightweight delegate that wraps text without expensive size recalculation."""
|
||||
|
||||
def __init__(self, parent=None, max_width=300, margin=6):
|
||||
super().__init__(parent)
|
||||
self._parent = parent
|
||||
self.max_width = max_width
|
||||
self.margin = margin
|
||||
self._cache = {} # cache text metrics for performance
|
||||
|
||||
def _do_custom_paint(self, painter, option, index, value: str):
|
||||
text = str(value)
|
||||
if not text:
|
||||
return
|
||||
painter.save()
|
||||
painter.setClipRect(option.rect)
|
||||
|
||||
# Use cached layout if available
|
||||
cache_key = (text, option.rect.width())
|
||||
layout = self._cache.get(cache_key)
|
||||
if layout is None:
|
||||
layout = QtGui.QTextLayout(text, option.font)
|
||||
layout.beginLayout()
|
||||
height = 0
|
||||
while True:
|
||||
line = layout.createLine()
|
||||
if not line.isValid():
|
||||
break
|
||||
line.setLineWidth(option.rect.width() - self.margin)
|
||||
line.setPosition(QtCore.QPointF(self.margin / 2, height))
|
||||
height += line.height()
|
||||
layout.endLayout()
|
||||
self._cache[cache_key] = layout
|
||||
|
||||
# # Draw background if selected
|
||||
# if option.state & QtWidgets.QStyle.State_Selected:
|
||||
# painter.fillRect(option.rect, option.palette.highlight())
|
||||
|
||||
# Draw text
|
||||
painter.setPen(option.palette.text().color())
|
||||
layout.draw(painter, option.rect.topLeft())
|
||||
|
||||
painter.restore()
|
||||
|
||||
def sizeHint(self, option, index):
|
||||
"""Return a cached or approximate height; avoids costly recomputation."""
|
||||
text = str(index.data(QtCore.Qt.DisplayRole) or "")
|
||||
view = self._parent
|
||||
view.initViewItemOption(option)
|
||||
if view.isColumnHidden(index.column()) or not view.isVisible() or not text:
|
||||
return QtCore.QSize(0, option.fontMetrics.height() + 2 * self.margin)
|
||||
|
||||
# Use cache for consistent size computation
|
||||
cache_key = (text, self.max_width)
|
||||
if cache_key in self._cache:
|
||||
layout = self._cache[cache_key]
|
||||
height = 0
|
||||
for i in range(layout.lineCount()):
|
||||
height += layout.lineAt(i).height()
|
||||
return QtCore.QSize(self.max_width, int(height + self.margin))
|
||||
|
||||
# Approximate without layout (fast path)
|
||||
metrics = option.fontMetrics
|
||||
pixel_width = max(self._parent.columnWidth(index.column()), 100)
|
||||
if pixel_width > 2000: # safeguard against uninitialized columns, may return large values
|
||||
pixel_width = 100
|
||||
char_per_line = self.estimate_chars_per_line(text, option, pixel_width - 2 * self.margin)
|
||||
wrapped_lines = textwrap.wrap(text, width=char_per_line)
|
||||
lines = len(wrapped_lines)
|
||||
return QtCore.QSize(pixel_width, lines * (metrics.height()) + 2 * self.margin)
|
||||
|
||||
def estimate_chars_per_line(self, text: str, option, column_width: int) -> int:
|
||||
"""Estimate number of characters that fit in a line for given width."""
|
||||
metrics = option.fontMetrics
|
||||
elided = metrics.elidedText(text, Qt.ElideRight, column_width)
|
||||
return len(elided.rstrip("…"))
|
||||
|
||||
@SafeSlot(int, int, int)
|
||||
def _on_section_resized(self, logical_index, old_size=None, new_size=None):
|
||||
"""Only update rows if a wrapped column was resized."""
|
||||
self._cache.clear()
|
||||
self._update_row_heights()
|
||||
|
||||
def _update_row_heights(self):
|
||||
"""Efficiently adjust row heights based on wrapped columns."""
|
||||
view = self._parent
|
||||
proxy = view.model()
|
||||
model = proxy.sourceModel()
|
||||
option = QtWidgets.QStyleOptionViewItem()
|
||||
view.initViewItemOption(option)
|
||||
# wrapping delegates
|
||||
wrap_delegate_columns = []
|
||||
for row in range(proxy.rowCount()):
|
||||
max_height = 18
|
||||
for column in [5, 6]: # TODO don't hardcode columns.. to be improved
|
||||
index = proxy.index(row, column)
|
||||
# model_index = proxy.mapToSource(index)
|
||||
# delegate = view.itemDelegateForColumn(model_index) or view.itemDelegate()
|
||||
delegate = view.itemDelegateForColumn(column)
|
||||
hint = delegate.sizeHint(option, index)
|
||||
max_height = max(max_height, hint.height())
|
||||
if view.rowHeight(row) != max_height:
|
||||
view.setRowHeight(row, max_height)
|
||||
|
||||
|
||||
class CenterCheckBoxDelegate(CustomDisplayDelegate):
|
||||
"""Custom checkbox delegate to center checkboxes in table cells."""
|
||||
|
||||
@@ -89,8 +199,9 @@ class CenterCheckBoxDelegate(CustomDisplayDelegate):
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
colors = get_accent_colors()
|
||||
self._icon_checked.setColor(colors.default)
|
||||
self._icon_unchecked.setColor(colors.default)
|
||||
_icon = partial(material_icon, size=(16, 16), color=colors.default, filled=True)
|
||||
self._icon_checked = _icon("check_box")
|
||||
self._icon_unchecked = _icon("check_box_outline_blank")
|
||||
|
||||
def _do_custom_paint(self, painter, option, index, value):
|
||||
pixmap = self._icon_checked if value == Qt.CheckState.Checked else self._icon_unchecked
|
||||
@@ -123,8 +234,12 @@ class DeviceValidatedDelegate(CustomDisplayDelegate):
|
||||
|
||||
def apply_theme(self, theme: str | None = None):
|
||||
colors = get_accent_colors()
|
||||
for status, icon in self._icons.items():
|
||||
icon.setColor(colors[status])
|
||||
_icon = partial(material_icon, icon_name="circle", size=(12, 12), filled=True)
|
||||
self._icons = {
|
||||
ValidationStatus.PENDING: _icon(color=colors.default),
|
||||
ValidationStatus.VALID: _icon(color=colors.success),
|
||||
ValidationStatus.FAILED: _icon(color=colors.emergency),
|
||||
}
|
||||
|
||||
def _do_custom_paint(self, painter, option, index, value):
|
||||
if pixmap := self._icons.get(value):
|
||||
@@ -148,15 +263,19 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
||||
self._device_config: list[dict[str, Any]] = []
|
||||
self._validation_status: dict[str, ValidationStatus] = {}
|
||||
self.headers = [
|
||||
"",
|
||||
"status",
|
||||
"name",
|
||||
"deviceClass",
|
||||
"readoutPriority",
|
||||
"onFailure",
|
||||
"deviceTags",
|
||||
"description",
|
||||
"enabled",
|
||||
"readOnly",
|
||||
"softwareTrigger",
|
||||
]
|
||||
self._checkable_columns_enabled = {"enabled": True, "readOnly": True}
|
||||
self._device_model_schema = Device.model_json_schema()
|
||||
|
||||
###############################################
|
||||
########## Override custom Qt methods #########
|
||||
@@ -172,6 +291,8 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
||||
|
||||
def headerData(self, section, orientation, role=int(Qt.ItemDataRole.DisplayRole)):
|
||||
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
|
||||
if section == 9: # softwareTrigger
|
||||
return "softTrig"
|
||||
return self.headers[section]
|
||||
return None
|
||||
|
||||
@@ -192,20 +313,24 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
||||
return self._validation_status.get(dev_name, ValidationStatus.PENDING)
|
||||
|
||||
key = self.headers[col]
|
||||
value = self._device_config[row].get(key)
|
||||
value = self._device_config[row].get(key, None)
|
||||
if value is None:
|
||||
value = (
|
||||
self._device_model_schema.get("properties", {}).get(key, {}).get("default", None)
|
||||
)
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if key in ("enabled", "readOnly"):
|
||||
if key in ("enabled", "readOnly", "softwareTrigger"):
|
||||
return bool(value)
|
||||
if key == "deviceTags":
|
||||
return ", ".join(str(tag) for tag in value) if value else ""
|
||||
if key == "deviceClass":
|
||||
return str(value).split(".")[-1]
|
||||
return str(value) if value is not None else ""
|
||||
if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly"):
|
||||
if role == USER_CHECK_DATA_ROLE and key in ("enabled", "readOnly", "softwareTrigger"):
|
||||
return Qt.CheckState.Checked if value else Qt.CheckState.Unchecked
|
||||
if role == Qt.ItemDataRole.TextAlignmentRole:
|
||||
if key in ("enabled", "readOnly"):
|
||||
if key in ("enabled", "readOnly", "softwareTrigger"):
|
||||
return Qt.AlignmentFlag.AlignCenter
|
||||
return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
if role == Qt.ItemDataRole.FontRole:
|
||||
@@ -223,7 +348,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
||||
Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDropEnabled
|
||||
)
|
||||
|
||||
if key in ("enabled", "readOnly"):
|
||||
if key in ("enabled", "readOnly", "softwareTrigger"):
|
||||
if self._checkable_columns_enabled.get(key, True):
|
||||
return base_flags | Qt.ItemFlag.ItemIsUserCheckable
|
||||
else:
|
||||
@@ -245,7 +370,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
||||
if not index.isValid():
|
||||
return False
|
||||
key = self.headers[index.column()]
|
||||
if key in ("enabled", "readOnly") and role == USER_CHECK_DATA_ROLE:
|
||||
if key in ("enabled", "readOnly", "softwareTrigger") and role == USER_CHECK_DATA_ROLE:
|
||||
if not self._checkable_columns_enabled.get(key, True):
|
||||
return False # ignore changes if column is disabled
|
||||
self._device_config[index.row()][key] = value == Qt.CheckState.Checked
|
||||
@@ -301,6 +426,7 @@ class DeviceTableModel(QtCore.QAbstractTableModel):
|
||||
if self._name_exists_in_config(name := cfg.get("name", "<not found>"), True):
|
||||
logger.warning(f"Device {name} already exists in the model.")
|
||||
already_in_list.append(name)
|
||||
# TODO add a warning that some devices were already in the list, how is this handled...
|
||||
continue
|
||||
row = len(self._device_config)
|
||||
self.beginInsertRows(QtCore.QModelIndex(), row, row)
|
||||
@@ -489,6 +615,12 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
|
||||
self._filter_text = ""
|
||||
self._enable_fuzzy = True
|
||||
self._filter_columns = [1, 2] # name and deviceClass for search
|
||||
# TODO refactor if enums are changed!!
|
||||
self._status_order = {
|
||||
ValidationStatus.VALID: 0,
|
||||
ValidationStatus.PENDING: 1,
|
||||
ValidationStatus.FAILED: 2,
|
||||
}
|
||||
|
||||
def get_row_data(self, rows: Iterable[QModelIndex]) -> Iterable[dict[str, Any]]:
|
||||
return (self.sourceModel().get_row_data(self.mapToSource(idx)) for idx in rows)
|
||||
@@ -506,6 +638,14 @@ class DeviceFilterProxyModel(QtCore.QSortFilterProxyModel):
|
||||
self._hidden_rows.update(row_indices)
|
||||
self.invalidateFilter()
|
||||
|
||||
def lessThan(self, left, right):
|
||||
"""Add custom sorting for the status column"""
|
||||
if left.column() != 0 or right.column() != 0:
|
||||
return super().lessThan(left, right)
|
||||
left_data = self.sourceModel().data(left, Qt.ItemDataRole.DisplayRole)
|
||||
right_data = self.sourceModel().data(right, Qt.ItemDataRole.DisplayRole)
|
||||
return self._status_order.get(left_data, 99) < self._status_order.get(right_data, 99)
|
||||
|
||||
def show_rows(self, row_indices: list[int]):
|
||||
"""
|
||||
Show specific rows in the model.
|
||||
@@ -602,6 +742,21 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||
# Connect signals
|
||||
self._model.configs_changed.connect(self.device_configs_changed.emit)
|
||||
|
||||
def get_help_md(self) -> str:
|
||||
"""
|
||||
Generate Markdown help for a cell or header.
|
||||
"""
|
||||
pos = self.table.mapFromGlobal(QtGui.QCursor.pos())
|
||||
model: DeviceTableModel = self._model # access underlying model
|
||||
index = self.table.indexAt(pos)
|
||||
if index.isValid():
|
||||
column = index.column()
|
||||
label = model.headerData(column, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole)
|
||||
if label == "softTrig":
|
||||
label = "softwareTrigger"
|
||||
return HEADERS_HELP_MD.get(label, "")
|
||||
return ""
|
||||
|
||||
def _setup_search(self):
|
||||
"""Create components related to the search functionality"""
|
||||
|
||||
@@ -653,15 +808,20 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||
self.checkbox_delegate = CenterCheckBoxDelegate(self.table, colors=colors)
|
||||
self.tool_tip_delegate = DictToolTipDelegate(self.table)
|
||||
self.validated_delegate = DeviceValidatedDelegate(self.table, colors=colors)
|
||||
self.table.setItemDelegateForColumn(0, self.validated_delegate) # ValidationStatus
|
||||
self.wrapped_delegate = WrappingTextDelegate(self.table, max_width=300)
|
||||
# Add resize handling for wrapped delegate
|
||||
header = self.table.horizontalHeader()
|
||||
|
||||
self.table.setItemDelegateForColumn(0, self.validated_delegate) # status
|
||||
self.table.setItemDelegateForColumn(1, self.tool_tip_delegate) # name
|
||||
self.table.setItemDelegateForColumn(2, self.tool_tip_delegate) # deviceClass
|
||||
self.table.setItemDelegateForColumn(3, self.tool_tip_delegate) # readoutPriority
|
||||
self.table.setItemDelegateForColumn(
|
||||
4, self.tool_tip_delegate
|
||||
) # deviceTags (was wrap_delegate)
|
||||
self.table.setItemDelegateForColumn(5, self.checkbox_delegate) # enabled
|
||||
self.table.setItemDelegateForColumn(6, self.checkbox_delegate) # readOnly
|
||||
self.table.setItemDelegateForColumn(4, self.tool_tip_delegate) # onFailure
|
||||
self.table.setItemDelegateForColumn(5, self.wrapped_delegate) # deviceTags
|
||||
self.table.setItemDelegateForColumn(6, self.wrapped_delegate) # description
|
||||
self.table.setItemDelegateForColumn(7, self.checkbox_delegate) # enabled
|
||||
self.table.setItemDelegateForColumn(8, self.checkbox_delegate) # readOnly
|
||||
self.table.setItemDelegateForColumn(9, self.checkbox_delegate) # softwareTrigger
|
||||
|
||||
# Disable wrapping, use eliding, and smooth scrolling
|
||||
self.table.setWordWrap(False)
|
||||
@@ -675,19 +835,35 @@ class DeviceTableView(BECWidget, QtWidgets.QWidget):
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # name
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # deviceClass
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Interactive) # readoutPriority
|
||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # deviceTags: expand to fill
|
||||
header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # enabled
|
||||
header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # readOnly
|
||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Interactive) # onFailure
|
||||
header.setSectionResizeMode(
|
||||
5, QHeaderView.ResizeMode.Interactive
|
||||
) # deviceTags: expand to fill
|
||||
header.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) # descript: expand to fill
|
||||
header.setSectionResizeMode(7, QHeaderView.ResizeMode.Fixed) # enabled
|
||||
header.setSectionResizeMode(8, QHeaderView.ResizeMode.Fixed) # readOnly
|
||||
header.setSectionResizeMode(9, QHeaderView.ResizeMode.Fixed) # softwareTrigger
|
||||
|
||||
self.table.setColumnWidth(0, 25)
|
||||
self.table.setColumnWidth(5, 70)
|
||||
self.table.setColumnWidth(6, 70)
|
||||
self.table.setColumnWidth(0, 70)
|
||||
self.table.setColumnWidth(5, 200)
|
||||
self.table.setColumnWidth(6, 200)
|
||||
self.table.setColumnWidth(7, 70)
|
||||
self.table.setColumnWidth(8, 70)
|
||||
self.table.setColumnWidth(9, 70)
|
||||
|
||||
# Ensure column widths stay fixed
|
||||
header.setMinimumSectionSize(25)
|
||||
header.setDefaultSectionSize(90)
|
||||
header.setStretchLastSection(False)
|
||||
|
||||
# Resize policy for wrapped text delegate
|
||||
self._resize_proxy = BECSignalProxy(
|
||||
header.sectionResized,
|
||||
rateLimit=25,
|
||||
slot=self.wrapped_delegate._on_section_resized,
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
# Selection behavior
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
|
||||
@@ -322,28 +322,36 @@ class DMOphydTest(BECWidget, QtWidgets.QWidget):
|
||||
self.validation_msg_md.emit("")
|
||||
|
||||
def _format_markdown_text(self, device_name: str, raw_msg: str) -> str:
|
||||
"""Simple HTML formatting for validation messages, wrapping text naturally."""
|
||||
if not raw_msg.strip():
|
||||
return f"### Validation in progress for {device_name}... \n\n"
|
||||
if raw_msg == "Validation in progress...":
|
||||
"""
|
||||
Simple HTML formatting for validation messages, wrapping text naturally.
|
||||
|
||||
Args:
|
||||
device_name (str): The name of the device.
|
||||
raw_msg (str): The raw validation message.
|
||||
"""
|
||||
if not raw_msg.strip() or raw_msg.strip() == "Validation in progress...":
|
||||
return f"### Validation in progress for {device_name}... \n\n"
|
||||
|
||||
m = re.search(r"ERROR:\s*([^\s]+)\s+is not valid:\s*(.+?errors?)", raw_msg)
|
||||
device, summary = m.group(1), m.group(2)
|
||||
lines = [f"## Error for '{device}'", f"'{device}' is not valid: {summary}"]
|
||||
|
||||
# Find each field block: \n<field>\n Field required ...
|
||||
field_pat = re.compile(
|
||||
r"\n(?P<field>\w+)\n\s+(?P<rest>Field required.*?(?=\n\w+\n|$))", re.DOTALL
|
||||
# Regex to capture repeated ERROR patterns
|
||||
pat = re.compile(
|
||||
r"ERROR:\s*(?P<device>[^\s]+)\s+"
|
||||
r"(?P<status>is not valid|is not connectable|failed):\s*"
|
||||
r"(?P<detail>.*?)(?=ERROR:|$)",
|
||||
re.DOTALL,
|
||||
)
|
||||
blocks = []
|
||||
for m in pat.finditer(raw_msg):
|
||||
dev = m.group("device")
|
||||
status = m.group("status")
|
||||
detail = m.group("detail").strip()
|
||||
lines = [f"## Error for {dev}", f"**{dev} {status}**", f"```\n{detail}\n```"]
|
||||
blocks.append("\n\n".join(lines))
|
||||
|
||||
for m in field_pat.finditer(raw_msg):
|
||||
field = m.group("field")
|
||||
rest = m.group("rest").rstrip()
|
||||
lines.append(f"### {field}")
|
||||
lines.append(rest)
|
||||
# Fallback: If no patterns matched, return the raw message
|
||||
if not blocks:
|
||||
return f"## Error for {device_name}\n```\n{raw_msg.strip()}\n```"
|
||||
|
||||
return "\n".join(lines)
|
||||
return "\n\n---\n\n".join(blocks)
|
||||
|
||||
def validation_running(self):
|
||||
return self._device_list_items != {}
|
||||
@@ -386,7 +394,7 @@ if __name__ == "__main__":
|
||||
layout.setSpacing(0)
|
||||
device_manager_ophyd_test = DMOphydTest()
|
||||
try:
|
||||
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/endstation.yaml"
|
||||
config_path = "/Users/appel_c/work_psi_awi/bec_workspace/csaxs_bec/csaxs_bec/device_configs/first_light.yaml"
|
||||
config = [{"name": k, **v} for k, v in yaml_load(config_path).items()]
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config: {e}")
|
||||
|
||||
@@ -1,452 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import PySide6QtAds as QtAds
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.macro_update_handler import has_executable_code
|
||||
from PySide6QtAds import CDockWidget
|
||||
from qtpy.QtCore import QEvent, QTimer, Signal
|
||||
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class MonacoDock(BECWidget, QWidget):
|
||||
"""
|
||||
MonacoDock is a dock widget that contains Monaco editor instances.
|
||||
It is used to manage multiple Monaco editors in a dockable interface.
|
||||
"""
|
||||
|
||||
focused_editor = Signal(object) # Emitted when the focused editor changes
|
||||
save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled
|
||||
signature_help = Signal(str) # Emitted when signature help is requested
|
||||
macro_file_updated = Signal(str) # Emitted when a macro file is saved
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
# Top-level layout hosting a toolbar and the dock manager
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_layout.setSpacing(0)
|
||||
|
||||
self.dock_manager = QtAds.CDockManager(self)
|
||||
self.dock_manager.setStyleSheet("")
|
||||
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
|
||||
self._root_layout.addWidget(self.dock_manager)
|
||||
self.dock_manager.installEventFilter(self)
|
||||
self._last_focused_editor: MonacoWidget | None = None
|
||||
self.focused_editor.connect(self._on_last_focused_editor_changed)
|
||||
self.add_editor()
|
||||
self._open_files = {}
|
||||
|
||||
def _create_editor(self):
|
||||
widget = MonacoWidget(self)
|
||||
widget.save_enabled.connect(self.save_enabled.emit)
|
||||
widget.editor.signature_help_triggered.connect(self._on_signature_change)
|
||||
count = len(self.dock_manager.dockWidgets())
|
||||
dock = CDockWidget(f"Untitled_{count + 1}")
|
||||
dock.setWidget(widget)
|
||||
|
||||
# Connect to modification status changes to update tab titles
|
||||
widget.save_enabled.connect(
|
||||
lambda modified: self._update_tab_title_for_modification(dock, modified)
|
||||
)
|
||||
|
||||
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
|
||||
dock.setFeature(CDockWidget.CustomCloseHandling, True)
|
||||
dock.setFeature(CDockWidget.DockWidgetClosable, True)
|
||||
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
|
||||
dock.setFeature(CDockWidget.DockWidgetMovable, True)
|
||||
|
||||
dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget))
|
||||
|
||||
return dock
|
||||
|
||||
@property
|
||||
def last_focused_editor(self) -> CDockWidget | None:
|
||||
"""
|
||||
Get the last focused editor.
|
||||
"""
|
||||
return self._last_focused_editor
|
||||
|
||||
@last_focused_editor.setter
|
||||
def last_focused_editor(self, editor: CDockWidget | None):
|
||||
self._last_focused_editor = editor
|
||||
self.focused_editor.emit(editor)
|
||||
|
||||
def _on_last_focused_editor_changed(self, editor: CDockWidget | None):
|
||||
if editor is None:
|
||||
self.save_enabled.emit(False)
|
||||
return
|
||||
|
||||
widget = cast(MonacoWidget, editor.widget())
|
||||
if widget.modified:
|
||||
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
|
||||
self.save_enabled.emit(widget.modified)
|
||||
|
||||
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
|
||||
"""Update the tab title to show modification status with a dot indicator."""
|
||||
current_title = dock.windowTitle()
|
||||
|
||||
# Remove existing modification indicator (dot and space)
|
||||
if current_title.startswith("• "):
|
||||
base_title = current_title[2:] # Remove "• "
|
||||
else:
|
||||
base_title = current_title
|
||||
|
||||
# Add or remove the modification indicator
|
||||
if modified:
|
||||
new_title = f"• {base_title}"
|
||||
else:
|
||||
new_title = base_title
|
||||
|
||||
dock.setWindowTitle(new_title)
|
||||
|
||||
def _on_signature_change(self, signature: dict):
|
||||
signatures = signature.get("signatures", [])
|
||||
if not signatures:
|
||||
self.signature_help.emit("")
|
||||
return
|
||||
|
||||
active_sig = signatures[signature.get("activeSignature", 0)]
|
||||
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
|
||||
|
||||
# Get signature label and documentation
|
||||
label = active_sig.get("label", "")
|
||||
doc_obj = active_sig.get("documentation", {})
|
||||
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
|
||||
|
||||
# Format the markdown output
|
||||
markdown = f"```python\n{label}\n```\n\n{documentation}"
|
||||
self.signature_help.emit(markdown)
|
||||
|
||||
def _on_focus_event(self, old_widget, new_widget) -> None:
|
||||
# Track focus events for the dock widget
|
||||
widget = new_widget.widget()
|
||||
if isinstance(widget, MonacoWidget):
|
||||
self.last_focused_editor = new_widget
|
||||
|
||||
def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget):
|
||||
# Cast widget to MonacoWidget since we know that's what it is
|
||||
monaco_widget = cast(MonacoWidget, widget)
|
||||
|
||||
# Check if we have unsaved changes
|
||||
if monaco_widget.modified:
|
||||
# Prompt the user to save changes
|
||||
response = QMessageBox.question(
|
||||
self,
|
||||
"Unsaved Changes",
|
||||
"You have unsaved changes. Do you want to save them?",
|
||||
QMessageBox.StandardButton.Yes
|
||||
| QMessageBox.StandardButton.No
|
||||
| QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
if response == QMessageBox.StandardButton.Yes:
|
||||
self.save_file(monaco_widget)
|
||||
elif response == QMessageBox.StandardButton.Cancel:
|
||||
return
|
||||
|
||||
# Count all editor docks managed by this dock manager
|
||||
total = len(self.dock_manager.dockWidgets())
|
||||
if total <= 1:
|
||||
# Do not remove the last dock; just wipe its editor content
|
||||
# Temporarily disable read-only mode if the editor is read-only
|
||||
# so we can clear the content for reuse
|
||||
monaco_widget.set_readonly(False)
|
||||
monaco_widget.set_text("")
|
||||
dock.setWindowTitle("Untitled")
|
||||
dock.setTabToolTip("Untitled")
|
||||
return
|
||||
|
||||
# Otherwise, proceed to close and delete the dock
|
||||
monaco_widget.close()
|
||||
dock.closeDockWidget()
|
||||
dock.deleteDockWidget()
|
||||
if self.last_focused_editor is dock:
|
||||
self.last_focused_editor = None
|
||||
# After topology changes, make sure single-tab areas get a plus button
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
|
||||
def _ensure_area_plus(self, area):
|
||||
if area is None:
|
||||
return
|
||||
# Only add once per area
|
||||
if getattr(area, "_monaco_plus_btn", None) is not None:
|
||||
return
|
||||
# If the area has exactly one tab, inject a + button next to the tab bar
|
||||
try:
|
||||
tabbar = area.titleBar().tabBar()
|
||||
count = tabbar.count() if hasattr(tabbar, "count") else 1
|
||||
except Exception:
|
||||
count = 1
|
||||
if count >= 1:
|
||||
plus_btn = QToolButton(area)
|
||||
plus_btn.setText("+")
|
||||
plus_btn.setToolTip("New Monaco Editor")
|
||||
plus_btn.setAutoRaise(True)
|
||||
tb = area.titleBar()
|
||||
idx = tb.indexOf(tb.tabBar())
|
||||
tb.insertWidget(idx + 1, plus_btn)
|
||||
plus_btn.clicked.connect(lambda: self.add_editor(area))
|
||||
# pylint: disable=protected-access
|
||||
area._monaco_plus_btn = plus_btn
|
||||
|
||||
def _scan_and_fix_areas(self):
|
||||
# Find all dock areas under this manager and ensure each single-tab area has a plus button
|
||||
areas = self.dock_manager.findChildren(QtAds.CDockAreaWidget)
|
||||
for a in areas:
|
||||
self._ensure_area_plus(a)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
# Track dock manager events
|
||||
if obj is self.dock_manager and event.type() in (
|
||||
QEvent.Type.ChildAdded,
|
||||
QEvent.Type.ChildRemoved,
|
||||
QEvent.Type.LayoutRequest,
|
||||
):
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def add_editor(
|
||||
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
|
||||
): # Any as qt ads does not return a proper type
|
||||
"""
|
||||
Adds a new Monaco editor dock widget to the dock manager.
|
||||
"""
|
||||
new_dock = self._create_editor()
|
||||
if title is not None:
|
||||
new_dock.setWindowTitle(title)
|
||||
if tooltip is not None:
|
||||
new_dock.setTabToolTip(tooltip)
|
||||
if area is None:
|
||||
area_obj = self.dock_manager.addDockWidgetTab(QtAds.TopDockWidgetArea, new_dock)
|
||||
self._ensure_area_plus(area_obj)
|
||||
else:
|
||||
# If an area is provided, add the dock to that area
|
||||
self.dock_manager.addDockWidgetTabToArea(new_dock, area)
|
||||
self._ensure_area_plus(area)
|
||||
|
||||
QTimer.singleShot(0, self._scan_and_fix_areas)
|
||||
return new_dock
|
||||
|
||||
def open_file(self, file_name: str, scope: str | None = None) -> None:
|
||||
"""
|
||||
Open a file in the specified area. If the file is already open, activate it.
|
||||
"""
|
||||
open_files = self._get_open_files()
|
||||
if file_name in open_files:
|
||||
dock = self._get_editor_dock(file_name)
|
||||
if dock is not None:
|
||||
dock.setAsCurrentTab()
|
||||
return
|
||||
|
||||
file = os.path.basename(file_name)
|
||||
# If the current editor is empty, we reuse it
|
||||
|
||||
# For now, the dock manager is only for the editor docks. We can therefore safely assume
|
||||
# that all docks are editor docks.
|
||||
dock_area = self.dock_manager.dockArea(0)
|
||||
|
||||
editor_dock = dock_area.currentDockWidget()
|
||||
editor_widget = editor_dock.widget() if editor_dock else None
|
||||
if editor_widget:
|
||||
editor_widget = cast(MonacoWidget, editor_dock.widget())
|
||||
if editor_widget.current_file is None and editor_widget.get_text() == "":
|
||||
editor_dock.setWindowTitle(file)
|
||||
editor_dock.setTabToolTip(file_name)
|
||||
editor_widget.open_file(file_name)
|
||||
editor_widget.metadata["scope"] = scope
|
||||
return
|
||||
|
||||
# File is not open, create a new editor
|
||||
editor_dock = self.add_editor(title=file, tooltip=file_name)
|
||||
widget = cast(MonacoWidget, editor_dock.widget())
|
||||
widget.open_file(file_name)
|
||||
widget.metadata["scope"] = scope
|
||||
|
||||
def save_file(
|
||||
self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True
|
||||
) -> None:
|
||||
"""
|
||||
Save the currently focused file.
|
||||
|
||||
Args:
|
||||
widget (MonacoWidget | None): The widget to save. If None, the last focused editor will be used.
|
||||
force_save_as (bool): If True, the "Save As" dialog will be shown even if the file is already saved.
|
||||
"""
|
||||
if widget is None:
|
||||
widget = self.last_focused_editor.widget() if self.last_focused_editor else None
|
||||
if not widget:
|
||||
return
|
||||
if "macros" in widget.metadata.get("scope", ""):
|
||||
if not self._validate_macros(widget.get_text()):
|
||||
return
|
||||
|
||||
if widget.current_file and not force_save_as:
|
||||
if format_on_save and pathlib.Path(widget.current_file).suffix == ".py":
|
||||
widget.format()
|
||||
|
||||
with open(widget.current_file, "w", encoding="utf-8") as f:
|
||||
f.write(widget.get_text())
|
||||
|
||||
if "macros" in widget.metadata.get("scope", ""):
|
||||
self._update_macros(widget)
|
||||
# Emit signal to refresh macro tree widget
|
||||
self.macro_file_updated.emit(widget.current_file)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
widget._original_content = widget.get_text()
|
||||
widget.save_enabled.emit(False)
|
||||
return
|
||||
|
||||
# Save as option
|
||||
save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)")
|
||||
|
||||
if save_file:
|
||||
# check if we have suffix specified
|
||||
file = pathlib.Path(save_file[0])
|
||||
if file.suffix == "":
|
||||
file = file.with_suffix(".py")
|
||||
if format_on_save and file.suffix == ".py":
|
||||
widget.format()
|
||||
|
||||
text = widget.get_text()
|
||||
with open(file, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
widget._original_content = text
|
||||
|
||||
# Update the current_file before emitting save_enabled to ensure proper tracking
|
||||
widget._current_file = str(file)
|
||||
widget.save_enabled.emit(False)
|
||||
|
||||
# Find the dock widget containing this monaco widget and update title
|
||||
for dock in self.dock_manager.dockWidgets():
|
||||
if dock.widget() == widget:
|
||||
dock.setWindowTitle(file.name)
|
||||
dock.setTabToolTip(str(file))
|
||||
break
|
||||
if "macros" in widget.metadata.get("scope", ""):
|
||||
self._update_macros(widget)
|
||||
# Emit signal to refresh macro tree widget
|
||||
self.macro_file_updated.emit(str(file))
|
||||
|
||||
print(f"Save file called, last focused editor: {self.last_focused_editor}")
|
||||
|
||||
def _validate_macros(self, source: str) -> bool:
|
||||
# pylint: disable=protected-access
|
||||
# Ensure the macro does not contain executable code before saving
|
||||
exec_code, line_number = has_executable_code(source)
|
||||
if exec_code:
|
||||
if line_number is None:
|
||||
msg = "The macro contains executable code. Please remove it before saving."
|
||||
else:
|
||||
msg = f"The macro contains executable code on line {line_number}. Please remove it before saving."
|
||||
QMessageBox.warning(self, "Save Error", msg)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _update_macros(self, widget: MonacoWidget):
|
||||
# pylint: disable=protected-access
|
||||
if not widget.current_file:
|
||||
return
|
||||
# Check which macros have changed and broadcast the change
|
||||
macros = self.client.macros._update_handler.get_macros_from_file(widget.current_file)
|
||||
existing_macros = self.client.macros._update_handler.get_existing_macros(
|
||||
widget.current_file
|
||||
)
|
||||
|
||||
removed_macros = set(existing_macros.keys()) - set(macros.keys())
|
||||
added_macros = set(macros.keys()) - set(existing_macros.keys())
|
||||
for name, info in macros.items():
|
||||
if name in added_macros:
|
||||
self.client.macros._update_handler.broadcast(
|
||||
action="add", name=name, file_path=widget.current_file
|
||||
)
|
||||
if (
|
||||
name in existing_macros
|
||||
and info.get("source", "") != existing_macros[name]["source"]
|
||||
):
|
||||
self.client.macros._update_handler.broadcast(
|
||||
action="reload", name=name, file_path=widget.current_file
|
||||
)
|
||||
for name in removed_macros:
|
||||
self.client.macros._update_handler.broadcast(action="remove", name=name)
|
||||
|
||||
def set_vim_mode(self, enabled: bool):
|
||||
"""
|
||||
Set Vim mode for all editor widgets.
|
||||
|
||||
Args:
|
||||
enabled (bool): Whether to enable or disable Vim mode.
|
||||
"""
|
||||
for widget in self.dock_manager.dockWidgets():
|
||||
editor_widget = cast(MonacoWidget, widget.widget())
|
||||
editor_widget.set_vim_mode_enabled(enabled)
|
||||
|
||||
def _get_open_files(self) -> list[str]:
|
||||
open_files = []
|
||||
for widget in self.dock_manager.dockWidgets():
|
||||
editor_widget = cast(MonacoWidget, widget.widget())
|
||||
if editor_widget.current_file is not None:
|
||||
open_files.append(editor_widget.current_file)
|
||||
return open_files
|
||||
|
||||
def _get_editor_dock(self, file_name: str) -> CDockWidget | None:
|
||||
for widget in self.dock_manager.dockWidgets():
|
||||
editor_widget = cast(MonacoWidget, widget.widget())
|
||||
if editor_widget.current_file == file_name:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def set_file_readonly(self, file_name: str, read_only: bool = True) -> bool:
|
||||
"""
|
||||
Set a specific file's editor to read-only mode.
|
||||
|
||||
Args:
|
||||
file_name (str): The file path to set read-only
|
||||
read_only (bool): Whether to set read-only mode (default: True)
|
||||
|
||||
Returns:
|
||||
bool: True if the file was found and read-only was set, False otherwise
|
||||
"""
|
||||
editor_dock = self._get_editor_dock(file_name)
|
||||
if editor_dock:
|
||||
editor_widget = cast(MonacoWidget, editor_dock.widget())
|
||||
editor_widget.set_readonly(read_only)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_file_icon(self, file_name: str, icon) -> bool:
|
||||
"""
|
||||
Set an icon for a specific file's tab.
|
||||
|
||||
Args:
|
||||
file_name (str): The file path to set icon for
|
||||
icon: The QIcon to set on the tab
|
||||
|
||||
Returns:
|
||||
bool: True if the file was found and icon was set, False otherwise
|
||||
"""
|
||||
editor_dock = self._get_editor_dock(file_name)
|
||||
if editor_dock:
|
||||
editor_dock.setIcon(icon)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
_dock = MonacoDock()
|
||||
_dock.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -1,19 +1,11 @@
|
||||
import os
|
||||
import traceback
|
||||
from typing import Literal
|
||||
|
||||
import black
|
||||
import isort
|
||||
import qtmonaco
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QApplication, QDialog, QVBoxLayout, QWidget
|
||||
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.utils.error_popups import SafeSlot
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class MonacoWidget(BECWidget, QWidget):
|
||||
@@ -22,7 +14,6 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
|
||||
text_changed = Signal(str)
|
||||
save_enabled = Signal(bool)
|
||||
PLUGIN = True
|
||||
ICON_NAME = "code"
|
||||
USER_ACCESS = [
|
||||
@@ -30,7 +21,6 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"get_text",
|
||||
"insert_text",
|
||||
"delete_line",
|
||||
"open_file",
|
||||
"set_language",
|
||||
"get_language",
|
||||
"set_theme",
|
||||
@@ -57,20 +47,7 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
layout.addWidget(self.editor)
|
||||
self.setLayout(layout)
|
||||
self.editor.text_changed.connect(self.text_changed.emit)
|
||||
self.editor.text_changed.connect(self._check_save_status)
|
||||
self.editor.initialized.connect(self.apply_theme)
|
||||
self.editor.initialized.connect(self._setup_context_menu)
|
||||
self.editor.context_menu_action_triggered.connect(self._handle_context_menu_action)
|
||||
self._current_file = None
|
||||
self._original_content = ""
|
||||
self.metadata = {}
|
||||
|
||||
@property
|
||||
def current_file(self):
|
||||
"""
|
||||
Get the current file being edited.
|
||||
"""
|
||||
return self._current_file
|
||||
|
||||
def apply_theme(self, theme: str | None = None) -> None:
|
||||
"""
|
||||
@@ -84,17 +61,14 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
editor_theme = "vs" if theme == "light" else "vs-dark"
|
||||
self.set_theme(editor_theme)
|
||||
|
||||
def set_text(self, text: str, file_name: str | None = None) -> None:
|
||||
def set_text(self, text: str) -> None:
|
||||
"""
|
||||
Set the text in the Monaco editor.
|
||||
|
||||
Args:
|
||||
text (str): The text to set in the editor.
|
||||
file_name (str): Set the file name
|
||||
"""
|
||||
self._current_file = file_name
|
||||
self._original_content = text
|
||||
self.editor.set_text(text, uri=file_name)
|
||||
self.editor.set_text(text)
|
||||
|
||||
def get_text(self) -> str:
|
||||
"""
|
||||
@@ -102,32 +76,6 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
return self.editor.get_text()
|
||||
|
||||
def format(self) -> None:
|
||||
"""
|
||||
Format the current text in the Monaco editor.
|
||||
"""
|
||||
if not self.editor:
|
||||
return
|
||||
try:
|
||||
content = self.get_text()
|
||||
try:
|
||||
formatted_content = black.format_str(content, mode=black.Mode(line_length=100))
|
||||
except Exception: # black.NothingChanged or other formatting exceptions
|
||||
formatted_content = content
|
||||
|
||||
config = isort.Config(
|
||||
profile="black",
|
||||
line_length=100,
|
||||
multi_line_output=3,
|
||||
include_trailing_comma=False,
|
||||
known_first_party=["bec_widgets"],
|
||||
)
|
||||
formatted_content = isort.code(formatted_content, config=config)
|
||||
self.set_text(formatted_content, file_name=self.current_file)
|
||||
except Exception:
|
||||
content = traceback.format_exc()
|
||||
logger.info(content)
|
||||
|
||||
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.
|
||||
@@ -148,32 +96,6 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
self.editor.delete_line(line)
|
||||
|
||||
def open_file(self, file_name: str) -> None:
|
||||
"""
|
||||
Open a file in the editor.
|
||||
|
||||
Args:
|
||||
file_name (str): The path + file name of the file that needs to be displayed.
|
||||
"""
|
||||
|
||||
if not os.path.exists(file_name):
|
||||
raise FileNotFoundError(f"The specified file does not exist: {file_name}")
|
||||
|
||||
with open(file_name, "r", encoding="utf-8") as file:
|
||||
content = file.read()
|
||||
self.set_text(content, file_name=file_name)
|
||||
|
||||
@property
|
||||
def modified(self) -> bool:
|
||||
"""
|
||||
Check if the editor content has been modified.
|
||||
"""
|
||||
return self._original_content != self.get_text()
|
||||
|
||||
@SafeSlot(str)
|
||||
def _check_save_status(self, _text: str) -> None:
|
||||
self.save_enabled.emit(self.modified)
|
||||
|
||||
def set_cursor(
|
||||
self,
|
||||
line: int,
|
||||
@@ -291,36 +213,6 @@ class MonacoWidget(BECWidget, QWidget):
|
||||
"""
|
||||
return self.editor.get_lsp_header()
|
||||
|
||||
def _setup_context_menu(self):
|
||||
"""Setup custom context menu actions for the Monaco editor."""
|
||||
# Add the "Insert Scan" action to the context menu
|
||||
self.editor.add_action("insert_scan", "Insert Scan", "python")
|
||||
# Add the "Format Code" action to the context menu
|
||||
self.editor.add_action("format_code", "Format Code", "python")
|
||||
|
||||
def _handle_context_menu_action(self, action_id: str):
|
||||
"""Handle context menu action triggers."""
|
||||
if action_id == "insert_scan":
|
||||
self._show_scan_control_dialog()
|
||||
elif action_id == "format_code":
|
||||
self._format_code()
|
||||
|
||||
def _show_scan_control_dialog(self):
|
||||
"""Show the scan control dialog and insert the generated scan code."""
|
||||
# Import here to avoid circular imports
|
||||
from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog
|
||||
|
||||
dialog = ScanControlDialog(self, client=self.client)
|
||||
if dialog.exec_() == QDialog.DialogCode.Accepted:
|
||||
scan_code = dialog.get_scan_code()
|
||||
if scan_code:
|
||||
# Insert the scan code at the current cursor position
|
||||
self.insert_text(scan_code)
|
||||
|
||||
def _format_code(self):
|
||||
"""Format the current code in the editor."""
|
||||
self.format()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
qapp = QApplication([])
|
||||
@@ -342,7 +234,7 @@ if TYPE_CHECKING:
|
||||
scans: Scans
|
||||
|
||||
#######################################
|
||||
########## User Script ################
|
||||
########## User Script #####################
|
||||
#######################################
|
||||
|
||||
# This is a comment
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"""
|
||||
Scan Control Dialog for Monaco Editor
|
||||
|
||||
This module provides a dialog wrapper around the ScanControl widget,
|
||||
allowing users to configure and generate scan code that can be inserted
|
||||
into the Monaco editor.
|
||||
"""
|
||||
|
||||
from bec_lib.device import Device
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QPushButton, QVBoxLayout
|
||||
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ScanControlDialog(QDialog):
|
||||
"""
|
||||
Dialog window containing the ScanControl widget for generating scan code.
|
||||
|
||||
This dialog allows users to configure scan parameters and generates
|
||||
Python code that can be inserted into the Monaco editor.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, client=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Insert Scan")
|
||||
|
||||
# Store the client for passing to ScanControl
|
||||
self.client = client
|
||||
self._scan_code = ""
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(600, 800)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup the dialog UI with ScanControl widget and buttons."""
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Create the scan control widget
|
||||
self.scan_control = ScanControl(parent=self, client=self.client)
|
||||
self.scan_control.show_scan_control_buttons(False)
|
||||
layout.addWidget(self.scan_control)
|
||||
|
||||
# Create dialog buttons
|
||||
button_box = QDialogButtonBox(Qt.Orientation.Horizontal, self)
|
||||
|
||||
# Create custom buttons with appropriate text
|
||||
insert_button = QPushButton("Insert")
|
||||
cancel_button = QPushButton("Cancel")
|
||||
|
||||
button_box.addButton(insert_button, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
button_box.addButton(cancel_button, QDialogButtonBox.ButtonRole.RejectRole)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Connect button signals
|
||||
button_box.accepted.connect(self.accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
|
||||
def _generate_scan_code(self):
|
||||
"""Generate Python code for the configured scan."""
|
||||
try:
|
||||
# Get scan parameters from the scan control widget
|
||||
args, kwargs = self.scan_control.get_scan_parameters()
|
||||
scan_name = self.scan_control.current_scan
|
||||
|
||||
if not scan_name:
|
||||
self._scan_code = ""
|
||||
return
|
||||
|
||||
# Process arguments and add device prefix where needed
|
||||
processed_args = self._process_arguments_for_code_generation(args)
|
||||
processed_kwargs = self._process_kwargs_for_code_generation(kwargs)
|
||||
|
||||
# Generate the Python code string
|
||||
code_parts = []
|
||||
|
||||
# Process arguments and keyword arguments
|
||||
all_args = []
|
||||
|
||||
# Add positional arguments
|
||||
if processed_args:
|
||||
all_args.extend(processed_args)
|
||||
|
||||
# Add keyword arguments (excluding metadata)
|
||||
if processed_kwargs:
|
||||
kwargs_strs = [f"{k}={v}" for k, v in processed_kwargs.items() if k != "metadata"]
|
||||
all_args.extend(kwargs_strs)
|
||||
|
||||
# Join all arguments and create the scan call
|
||||
args_str = ", ".join(all_args)
|
||||
if args_str:
|
||||
code_parts.append(f"scans.{scan_name}({args_str})")
|
||||
else:
|
||||
code_parts.append(f"scans.{scan_name}()")
|
||||
|
||||
self._scan_code = "\n".join(code_parts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating scan code: {e}")
|
||||
self._scan_code = f"# Error generating scan code: {e}\n"
|
||||
|
||||
def _process_arguments_for_code_generation(self, args):
|
||||
"""Process arguments to add device prefixes and proper formatting."""
|
||||
return [self._format_value_for_code(arg) for arg in args]
|
||||
|
||||
def _process_kwargs_for_code_generation(self, kwargs):
|
||||
"""Process keyword arguments to add device prefixes and proper formatting."""
|
||||
return {key: self._format_value_for_code(value) for key, value in kwargs.items()}
|
||||
|
||||
def _format_value_for_code(self, value):
|
||||
"""Format a single value for code generation."""
|
||||
if isinstance(value, Device):
|
||||
return f"dev.{value.name}"
|
||||
return repr(value)
|
||||
|
||||
def get_scan_code(self) -> str:
|
||||
"""
|
||||
Get the generated scan code.
|
||||
|
||||
Returns:
|
||||
str: The Python code for the configured scan.
|
||||
"""
|
||||
return self._scan_code
|
||||
|
||||
def accept(self):
|
||||
"""Override accept to generate code before closing."""
|
||||
self._generate_scan_code()
|
||||
super().accept()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
dialog = ScanControlDialog()
|
||||
dialog.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -172,17 +172,9 @@ class WebConsole(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
config=None,
|
||||
client=None,
|
||||
gui_id=None,
|
||||
startup_cmd="bec --nogui",
|
||||
**kwargs,
|
||||
):
|
||||
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 = startup_cmd
|
||||
self._startup_cmd = "bec --nogui"
|
||||
self._is_initialized = False
|
||||
_web_console_registry.register(self)
|
||||
self._token = _web_console_registry._token
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import datetime
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import os
|
||||
import re
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
|
||||
from bec_widgets.widgets.containers.explorer.explorer import Explorer
|
||||
from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget
|
||||
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
|
||||
|
||||
|
||||
@@ -22,19 +17,16 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
RPC = False
|
||||
|
||||
file_open_requested = Signal(str, str)
|
||||
file_preview_requested = Signal(str, str)
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._sections = [] # Use list to maintain order instead of set
|
||||
self._sections = set()
|
||||
self.main_explorer = Explorer(parent=self)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.main_explorer)
|
||||
self.setLayout(layout)
|
||||
self.sections = ["scripts", "macros"]
|
||||
self.sections = ["scripts"]
|
||||
|
||||
@SafeProperty(list)
|
||||
def sections(self):
|
||||
@@ -43,16 +35,10 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
@sections.setter
|
||||
def sections(self, value):
|
||||
existing_sections = set(self._sections)
|
||||
new_sections = set(value)
|
||||
# Find sections to add, maintaining the order from the input value list
|
||||
sections_to_add = [
|
||||
section for section in value if section in (new_sections - existing_sections)
|
||||
]
|
||||
self._sections = list(value) # Store as ordered list
|
||||
self._update_section_visibility(sections_to_add)
|
||||
self._sections = set(value)
|
||||
self._update_section_visibility(self._sections - existing_sections)
|
||||
|
||||
def _update_section_visibility(self, sections):
|
||||
# sections is now an ordered list, not a set
|
||||
for section in sections:
|
||||
self._add_section(section)
|
||||
|
||||
@@ -60,18 +46,15 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
match section_name.lower():
|
||||
case "scripts":
|
||||
self.add_script_section()
|
||||
case "macros":
|
||||
self.add_macro_section()
|
||||
case _:
|
||||
pass
|
||||
|
||||
def add_script_section(self):
|
||||
section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0)
|
||||
section.expanded = False
|
||||
|
||||
script_explorer = Explorer(parent=self)
|
||||
script_widget = ScriptTreeWidget(parent=self)
|
||||
script_widget.file_open_requested.connect(self._emit_file_open_scripts_local)
|
||||
script_widget.file_selected.connect(self._emit_file_preview_scripts_local)
|
||||
local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
|
||||
local_scripts_section.header_add_button.clicked.connect(self._add_local_script)
|
||||
local_scripts_section.set_widget(script_widget)
|
||||
@@ -94,89 +77,15 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
|
||||
if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir):
|
||||
return
|
||||
shared_script_section = CollapsibleSection(title="Shared (Read-only)", parent=self)
|
||||
shared_script_section.setToolTip("Shared scripts (read-only)")
|
||||
shared_script_section = CollapsibleSection(title="Shared", parent=self)
|
||||
shared_script_widget = ScriptTreeWidget(parent=self)
|
||||
shared_script_section.set_widget(shared_script_widget)
|
||||
shared_script_widget.set_directory(plugin_scripts_dir)
|
||||
script_explorer.add_section(shared_script_section)
|
||||
shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared)
|
||||
shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared)
|
||||
# macros_section = CollapsibleSection("MACROS", indentation=0)
|
||||
# macros_section.set_widget(QLabel("Macros will be implemented later"))
|
||||
# self.main_explorer.add_section(macros_section)
|
||||
|
||||
def add_macro_section(self):
|
||||
section = CollapsibleSection(
|
||||
parent=self,
|
||||
title="MACROS",
|
||||
indentation=0,
|
||||
show_add_button=True,
|
||||
tooltip="Macros are reusable functions that can be called from scripts or the console.",
|
||||
)
|
||||
section.header_add_button.setIcon(material_icon("refresh", size=(20, 20)))
|
||||
section.header_add_button.setToolTip("Reload all macros")
|
||||
section.header_add_button.clicked.connect(self._reload_macros)
|
||||
|
||||
macro_explorer = Explorer(parent=self)
|
||||
macro_widget = MacroTreeWidget(parent=self)
|
||||
macro_widget.macro_open_requested.connect(self._emit_file_open_macros_local)
|
||||
macro_widget.macro_selected.connect(self._emit_file_preview_macros_local)
|
||||
local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
|
||||
local_macros_section.header_add_button.clicked.connect(self._add_local_macro)
|
||||
local_macros_section.set_widget(macro_widget)
|
||||
local_macro_dir = self.client._service_config.model.user_macros.base_path
|
||||
if not os.path.exists(local_macro_dir):
|
||||
os.makedirs(local_macro_dir)
|
||||
macro_widget.set_directory(local_macro_dir)
|
||||
macro_explorer.add_section(local_macros_section)
|
||||
|
||||
section.set_widget(macro_explorer)
|
||||
self.main_explorer.add_section(section)
|
||||
|
||||
plugin_macros_dir = None
|
||||
plugins = importlib.metadata.entry_points(group="bec")
|
||||
for plugin in plugins:
|
||||
if plugin.name == "plugin_bec":
|
||||
plugin = plugin.load()
|
||||
plugin_macros_dir = os.path.join(plugin.__path__[0], "macros")
|
||||
break
|
||||
|
||||
if not plugin_macros_dir or not os.path.exists(plugin_macros_dir):
|
||||
return
|
||||
shared_macro_section = CollapsibleSection(title="Shared (Read-only)", parent=self)
|
||||
shared_macro_section.setToolTip("Shared macros (read-only)")
|
||||
shared_macro_widget = MacroTreeWidget(parent=self)
|
||||
shared_macro_section.set_widget(shared_macro_widget)
|
||||
shared_macro_widget.set_directory(plugin_macros_dir)
|
||||
macro_explorer.add_section(shared_macro_section)
|
||||
shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared)
|
||||
shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared)
|
||||
|
||||
def _emit_file_open_scripts_local(self, file_name: str):
|
||||
self.file_open_requested.emit(file_name, "scripts/local")
|
||||
|
||||
def _emit_file_preview_scripts_local(self, file_name: str):
|
||||
self.file_preview_requested.emit(file_name, "scripts/local")
|
||||
|
||||
def _emit_file_open_scripts_shared(self, file_name: str):
|
||||
self.file_open_requested.emit(file_name, "scripts/shared")
|
||||
|
||||
def _emit_file_preview_scripts_shared(self, file_name: str):
|
||||
self.file_preview_requested.emit(file_name, "scripts/shared")
|
||||
|
||||
def _emit_file_open_macros_local(self, function_name: str, file_path: str):
|
||||
self.file_open_requested.emit(file_path, "macros/local")
|
||||
|
||||
def _emit_file_preview_macros_local(self, function_name: str, file_path: str):
|
||||
self.file_preview_requested.emit(file_path, "macros/local")
|
||||
|
||||
def _emit_file_open_macros_shared(self, function_name: str, file_path: str):
|
||||
self.file_open_requested.emit(file_path, "macros/shared")
|
||||
|
||||
def _emit_file_preview_macros_shared(self, function_name: str, file_path: str):
|
||||
self.file_preview_requested.emit(file_path, "macros/shared")
|
||||
|
||||
def _add_local_script(self):
|
||||
"""Show a dialog to enter the name of a new script and create it."""
|
||||
|
||||
@@ -227,134 +136,6 @@ class IDEExplorer(BECWidget, QWidget):
|
||||
# Show error if file creation failed
|
||||
QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}")
|
||||
|
||||
def _add_local_macro(self):
|
||||
"""Show a dialog to enter the name of a new macro function and create it."""
|
||||
|
||||
target_section = self.main_explorer.get_section("MACROS")
|
||||
macro_dir_section = target_section.content_widget.get_section("Local")
|
||||
|
||||
local_macro_dir = macro_dir_section.content_widget.directory
|
||||
|
||||
# Prompt user for function name
|
||||
function_name, ok = QInputDialog.getText(self, "New Macro", f"Enter macro function name:")
|
||||
|
||||
if not ok or not function_name:
|
||||
return # User cancelled or didn't enter a name
|
||||
|
||||
# Sanitize function name
|
||||
function_name = re.sub(r"[^a-zA-Z0-9_]", "_", function_name)
|
||||
if not function_name or function_name[0].isdigit():
|
||||
QMessageBox.warning(
|
||||
self, "Invalid Name", "Function name must be a valid Python identifier."
|
||||
)
|
||||
return
|
||||
|
||||
# Create filename based on function name
|
||||
filename = f"{function_name}.py"
|
||||
file_path = os.path.join(local_macro_dir, filename)
|
||||
|
||||
# Check if file already exists
|
||||
if os.path.exists(file_path):
|
||||
response = QMessageBox.question(
|
||||
self,
|
||||
"File exists",
|
||||
f"The file '{filename}' already exists. Do you want to overwrite it?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
|
||||
if response != QMessageBox.StandardButton.Yes:
|
||||
return # User chose not to overwrite
|
||||
|
||||
try:
|
||||
# Create the file with a macro function template
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f'''"""
|
||||
{function_name} macro - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
|
||||
"""
|
||||
|
||||
|
||||
def {function_name}():
|
||||
"""
|
||||
Description of what this macro does.
|
||||
|
||||
Add your macro implementation here.
|
||||
"""
|
||||
print(f"Executing macro: {function_name}")
|
||||
# TODO: Add your macro code here
|
||||
pass
|
||||
'''
|
||||
)
|
||||
|
||||
# Refresh the macro tree to show the new function
|
||||
macro_dir_section.content_widget.refresh()
|
||||
|
||||
except Exception as e:
|
||||
# Show error if file creation failed
|
||||
QMessageBox.critical(self, "Error", f"Failed to create macro: {str(e)}")
|
||||
|
||||
def _reload_macros(self):
|
||||
"""Reload all macros using the BEC client."""
|
||||
try:
|
||||
if hasattr(self.client, "macros"):
|
||||
self.client.macros.load_all_user_macros()
|
||||
|
||||
# Refresh the macro tree widgets to show updated functions
|
||||
target_section = self.main_explorer.get_section("MACROS")
|
||||
if target_section and hasattr(target_section, "content_widget"):
|
||||
local_section = target_section.content_widget.get_section("Local")
|
||||
if local_section and hasattr(local_section, "content_widget"):
|
||||
local_section.content_widget.refresh()
|
||||
|
||||
shared_section = target_section.content_widget.get_section("Shared")
|
||||
if shared_section and hasattr(shared_section, "content_widget"):
|
||||
shared_section.content_widget.refresh()
|
||||
|
||||
QMessageBox.information(
|
||||
self, "Reload Macros", "Macros have been reloaded successfully."
|
||||
)
|
||||
else:
|
||||
QMessageBox.warning(self, "Reload Macros", "Macros functionality is not available.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}")
|
||||
|
||||
def refresh_macro_file(self, file_path: str):
|
||||
"""Refresh a single macro file in the tree widget.
|
||||
|
||||
Args:
|
||||
file_path: Path to the macro file that was updated
|
||||
"""
|
||||
target_section = self.main_explorer.get_section("MACROS")
|
||||
if not target_section or not hasattr(target_section, "content_widget"):
|
||||
return
|
||||
|
||||
# Determine if this is a local or shared macro based on the file path
|
||||
local_section = target_section.content_widget.get_section("Local")
|
||||
shared_section = target_section.content_widget.get_section("Shared")
|
||||
|
||||
# Check if file belongs to local macros directory
|
||||
if (
|
||||
local_section
|
||||
and hasattr(local_section, "content_widget")
|
||||
and hasattr(local_section.content_widget, "directory")
|
||||
):
|
||||
local_macro_dir = local_section.content_widget.directory
|
||||
if local_macro_dir and file_path.startswith(local_macro_dir):
|
||||
local_section.content_widget.refresh_file_item(file_path)
|
||||
return
|
||||
|
||||
# Check if file belongs to shared macros directory
|
||||
if (
|
||||
shared_section
|
||||
and hasattr(shared_section, "content_widget")
|
||||
and hasattr(shared_section.content_widget, "directory")
|
||||
):
|
||||
shared_macro_dir = shared_section.content_widget.directory
|
||||
if shared_macro_dir and file_path.startswith(shared_macro_dir):
|
||||
shared_section.content_widget.refresh_file_item(file_path)
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
@@ -27,10 +27,6 @@ dependencies = [
|
||||
"qtmonaco~=0.7",
|
||||
"darkdetect~=0.8",
|
||||
"PySide6-QtAds==4.4.0",
|
||||
"pylsp-bec",
|
||||
"copier~=9.7",
|
||||
"typer~=0.15",
|
||||
"markdown~=3.9",
|
||||
]
|
||||
|
||||
|
||||
@@ -48,6 +44,7 @@ dev = [
|
||||
"pytest-cov~=6.1.1",
|
||||
"watchdog~=6.0",
|
||||
"pre_commit~=4.2",
|
||||
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
81
tests/unit_tests/test_help_inspector.py
Normal file
81
tests/unit_tests/test_help_inspector.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
|
||||
|
||||
import pytest
|
||||
from qtpy import QtCore, QtWidgets
|
||||
|
||||
from bec_widgets.utils.help_inspector.help_inspector import HelpInspector
|
||||
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def help_inspector(qtbot, mocked_client):
|
||||
widget = HelpInspector(client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def abort_button(qtbot):
|
||||
widget = AbortButton()
|
||||
widget.setToolTip("This is an abort button.")
|
||||
|
||||
def get_help_md():
|
||||
return "This is **markdown** help text for the abort button."
|
||||
|
||||
widget.get_help_md = get_help_md # type: ignore
|
||||
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
|
||||
yield widget
|
||||
|
||||
|
||||
def test_help_inspector_button(help_inspector):
|
||||
"""Test the HelpInspector widget."""
|
||||
assert not help_inspector._active
|
||||
help_inspector._button.click()
|
||||
assert help_inspector._active
|
||||
assert help_inspector._button.isChecked()
|
||||
cursor = QtWidgets.QApplication.overrideCursor()
|
||||
assert cursor is not None
|
||||
assert cursor.shape() == QtCore.Qt.CursorShape.WhatsThisCursor
|
||||
help_inspector._button.click()
|
||||
assert not help_inspector._active
|
||||
assert not help_inspector._button.isChecked()
|
||||
assert QtWidgets.QApplication.overrideCursor() is None
|
||||
|
||||
|
||||
def test_help_inspector_register_callback(help_inspector):
|
||||
"""Test registering a callback in the HelpInspector widget."""
|
||||
|
||||
assert len(help_inspector._callbacks) == 3 # default callbacks
|
||||
|
||||
def my_callback(widget):
|
||||
pass
|
||||
|
||||
cb_id = help_inspector.register_callback(my_callback)
|
||||
assert len(help_inspector._callbacks) == 4
|
||||
assert help_inspector._callbacks[cb_id] == my_callback
|
||||
|
||||
cb_id2 = help_inspector.register_callback(my_callback)
|
||||
assert len(help_inspector._callbacks) == 5
|
||||
assert help_inspector._callbacks[cb_id2] == my_callback
|
||||
|
||||
help_inspector.unregister_callback(cb_id)
|
||||
assert len(help_inspector._callbacks) == 4
|
||||
|
||||
help_inspector.unregister_callback(cb_id2)
|
||||
assert len(help_inspector._callbacks) == 3
|
||||
|
||||
|
||||
def test_help_inspector_escape_key(qtbot, help_inspector):
|
||||
"""Test that pressing the Escape key deactivates the HelpInspector."""
|
||||
help_inspector._button.click()
|
||||
assert help_inspector._active
|
||||
qtbot.keyClick(help_inspector, QtCore.Qt.Key.Key_Escape)
|
||||
assert not help_inspector._active
|
||||
assert not help_inspector._button.isChecked()
|
||||
assert QtWidgets.QApplication.overrideCursor() is None
|
||||
@@ -1,22 +0,0 @@
|
||||
import pytest
|
||||
from qtpy.QtWidgets import QTreeView, QWidget
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
|
||||
class DummyTree(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
tree = QTreeView(self)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree_widget(qtbot):
|
||||
tree = DummyTree()
|
||||
qtbot.addWidget(tree)
|
||||
qtbot.waitExposed(tree)
|
||||
yield tree
|
||||
|
||||
|
||||
def test_tree_widget_init(tree_widget):
|
||||
assert isinstance(tree_widget, QWidget)
|
||||
Reference in New Issue
Block a user