1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-08 01:37:52 +01:00

feat(developer_view): add developer view

This commit is contained in:
2025-08-18 16:47:16 +02:00
parent b4987fe759
commit 01755aba07
21 changed files with 4074 additions and 168 deletions

View File

@@ -3,6 +3,7 @@ from qtpy.QtWidgets import QApplication, QHBoxLayout, QStackedWidget, QWidget
from bec_widgets.applications.navigation_centre.reveal_animator import ANIMATION_DURATION
from bec_widgets.applications.navigation_centre.side_bar import SideBar
from bec_widgets.applications.navigation_centre.side_bar_components import NavigationItem
from bec_widgets.applications.views.developer_view.developer_view import DeveloperView
from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
DeviceManagerWidget,
)
@@ -48,6 +49,7 @@ 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"
@@ -59,6 +61,13 @@ 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")
@@ -142,6 +151,8 @@ 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)
@@ -195,7 +206,21 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([sys.argv[0], *qt_args])
apply_theme("dark")
w = BECMainApp(show_examples=args.examples)
w.resize(1920, 1200)
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.show()
sys.exit(app.exec())

View File

@@ -0,0 +1,63 @@
from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
from bec_widgets.applications.views.view import ViewBase
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_())

View File

@@ -0,0 +1,347 @@
import re
import markdown
from bec_lib.endpoints import MessageEndpoints
from bec_lib.script_executor import upload_script
from bec_qthemes import material_icon
from qtpy.QtGui import QKeySequence, QShortcut
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
from shiboken6 import isValid
import bec_widgets.widgets.containers.ads as QtAds
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.ads import CDockManager, CDockWidget
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.editors.monaco.monaco_dock 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))
)
self._current_script_id: str | None = None
# 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.DockWidgetFeature.DockWidgetClosable, False)
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetFeature.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_as_button.action.triggered.connect(self.on_save_as)
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):
if not self.current_script_id:
return
self.console.send_ctrl_c()
@property
def current_script_id(self):
return self._current_script_id
@current_script_id.setter
def current_script_id(self, value: str | None):
if value is not None and not isinstance(value, str):
raise ValueError("Script ID must be a string.")
old_script_id = self._current_script_id
self._current_script_id = value
self._update_subscription(value, old_script_id)
def _update_subscription(self, new_script_id: str | None, old_script_id: str | None):
if old_script_id is not None:
self.bec_dispatcher.disconnect_slot(
self.on_script_execution_info, MessageEndpoints.script_execution_info(old_script_id)
)
if new_script_id is not None:
self.bec_dispatcher.connect_slot(
self.on_script_execution_info, MessageEndpoints.script_execution_info(new_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)
def cleanup(self):
for dock in self.dock_manager.dockWidgets():
self._delete_dock(dock)
return super().cleanup()
def _delete_dock(self, dock: CDockWidget) -> None:
w = dock.widget()
if w and isValid(w):
w.close()
w.deleteLater()
if isValid(dock):
dock.closeDockWidget()
dock.deleteDockWidget()
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()
_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_())

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
from qtpy.QtCore import QEventLoop
from typing import List
from qtpy.QtCore import QEventLoop, Qt, QTimer
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
@@ -9,6 +11,7 @@ from qtpy.QtWidgets import (
QLabel,
QMessageBox,
QPushButton,
QSplitter,
QStackedLayout,
QVBoxLayout,
QWidget,
@@ -20,6 +23,42 @@ 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.
@@ -76,6 +115,68 @@ 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

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, Qt, Signal
from qtpy.QtGui import QDrag
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.error_popups import SafeProperty
@@ -24,7 +24,14 @@ 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):
def __init__(
self,
parent=None,
title="",
indentation=10,
show_add_button=False,
tooltip: str | None = None,
):
super().__init__(parent=parent)
self.title = title
self.content_widget = None
@@ -50,6 +57,8 @@ 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
@@ -57,13 +66,16 @@ class CollapsibleSection(QWidget):
header_layout.addWidget(self.header_button)
header_layout.addStretch()
self.header_add_button = QPushButton()
# Add button in header (icon-only)
self.header_add_button = QToolButton()
self.header_add_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
self.header_add_button.setFixedSize(20, 20)
self.header_add_button.setFixedSize(28, 28)
self.header_add_button.setToolTip("Add item")
self.header_add_button.setVisible(show_add_button)
self.header_add_button.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.header_add_button.setAutoRaise(True)
self.header_add_button.setIcon(material_icon("add", size=(20, 20)))
self.header_add_button.setIcon(material_icon("add", size=(28, 28), convert_to_pixmap=False))
header_layout.addWidget(self.header_add_button)
self.main_layout.addLayout(header_layout)
@@ -106,7 +118,6 @@ class CollapsibleSection(QWidget):
padding: 0px;
border: none;
background: transparent;
color: {text_color};
icon-size: 20px 20px;
}}
"""

View File

@@ -18,8 +18,8 @@ class Explorer(BECWidget, QWidget):
RPC = False
PLUGIN = False
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
# Main layout
self.main_layout = QVBoxLayout(self)

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
from typing import Any
from qtpy.QtCore import QModelIndex, QRect, QSortFilterProxyModel, Qt
from qtpy.QtGui import QPainter
from qtpy.QtWidgets import QAction, QStyledItemDelegate, QTreeView
from bec_widgets.utils.colors import get_theme_palette
class ExplorerDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover for the explorer"""
def __init__(self, parent=None):
super().__init__(parent)
self.hovered_index = QModelIndex()
self.button_rects: list[QRect] = []
self.current_macro_info = {}
self.target_model = QSortFilterProxyModel
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
tree_view = self.parent()
if not isinstance(tree_view, QTreeView):
return
proxy_model = tree_view.model()
if not isinstance(proxy_model, self.target_model):
return
actions = self.get_actions_for_current_item(proxy_model, index)
if actions:
self._draw_action_buttons(painter, option, 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 get_actions_for_current_item(self, model, index) -> list[QAction] | None:
"""Get actions for the current item based on its type"""
return None
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)
actions = self.get_actions_for_current_item(model, index)
if not actions:
return super().editorEvent(event, model, option, index)
# Check which button was clicked
visible_actions = [action for action in 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

View File

@@ -0,0 +1,382 @@
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 QStandardItem, QStandardItemModel
from qtpy.QtWidgets import QAction, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate
logger = bec_logger.logger
class MacroItemDelegate(ExplorerDelegate):
"""Custom delegate to show action buttons on hover for macro functions"""
def __init__(self, parent=None):
super().__init__(parent)
self.macro_actions: list[Any] = []
self.button_rects: list[QRect] = []
self.current_macro_info = {}
self.target_model = QStandardItemModel
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 get_actions_for_current_item(self, model, index) -> list[QAction] | None:
# 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
return self.macro_actions
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()

View File

@@ -2,32 +2,29 @@ import os
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 QAction, QPainter
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
from qtpy.QtCore import QModelIndex, QRegularExpression, QSortFilterProxyModel, Signal
from qtpy.QtWidgets import QFileSystemModel, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate
logger = bec_logger.logger
class FileItemDelegate(QStyledItemDelegate):
class FileItemDelegate(ExplorerDelegate):
"""Custom delegate to show action buttons on hover"""
def __init__(self, parent=None):
super().__init__(parent)
self.hovered_index = QModelIndex()
self.file_actions: list[QAction] = []
self.dir_actions: list[QAction] = []
self.button_rects: list[QRect] = []
self.current_file_path = ""
def __init__(self, tree_widget):
super().__init__(tree_widget)
self.file_actions = []
self.dir_actions = []
def add_file_action(self, action: QAction) -> None:
def add_file_action(self, action) -> None:
"""Add an action for files"""
self.file_actions.append(action)
def add_dir_action(self, action: QAction) -> None:
def add_dir_action(self, action) -> None:
"""Add an action for directories"""
self.dir_actions.append(action)
@@ -36,126 +33,18 @@ class FileItemDelegate(QStyledItemDelegate):
self.file_actions.clear()
self.dir_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
tree_view = self.parent()
if not isinstance(tree_view, QTreeView):
return
proxy_model = tree_view.model()
if not isinstance(proxy_model, QSortFilterProxyModel):
return
source_index = proxy_model.mapToSource(index)
source_model = proxy_model.sourceModel()
if not isinstance(source_model, QFileSystemModel):
return
is_dir = source_model.isDir(source_index)
file_path = source_model.filePath(source_index)
self.current_file_path = file_path
# Choose appropriate actions based on item type
actions = self.dir_actions if is_dir else self.file_actions
if actions:
self._draw_action_buttons(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
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)
# Early return if not a proxy model
def get_actions_for_current_item(self, model, index) -> list[MaterialIconAction] | None:
"""Get actions for the current item based on its type"""
if not isinstance(model, QSortFilterProxyModel):
return super().editorEvent(event, model, option, index)
return None
source_index = model.mapToSource(index)
source_model = model.sourceModel()
# Early return if not a file system model
if not isinstance(source_model, QFileSystemModel):
return super().editorEvent(event, model, option, index)
return None
is_dir = source_model.isDir(source_index)
actions = self.dir_actions if is_dir else self.file_actions
# Check which button was clicked
visible_actions = [action for action in 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
return self.dir_actions if is_dir else self.file_actions
class ScriptTreeWidget(QWidget):
@@ -229,12 +118,18 @@ 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;
@@ -286,14 +181,14 @@ class ScriptTreeWidget(QWidget):
return super().eventFilter(obj, event)
def set_directory(self, directory):
def set_directory(self, directory: str) -> None:
"""Set the scripts directory"""
self.directory = directory
# Early return if directory doesn't exist
if not directory or not os.path.exists(directory):
if not directory or not isinstance(directory, str) or not os.path.exists(directory):
return
self.directory = directory
root_index = self.model.setRootPath(directory)
# Map the source model index to proxy model index
proxy_root_index = self.proxy_model.mapFromSource(root_index)
@@ -357,11 +252,11 @@ class ScriptTreeWidget(QWidget):
self.file_open_requested.emit(file_path)
def add_file_action(self, action: QAction) -> None:
def add_file_action(self, action) -> None:
"""Add an action for file items"""
self.delegate.add_file_action(action)
def add_dir_action(self, action: QAction) -> None:
def add_dir_action(self, action) -> None:
"""Add an action for directory items"""
self.delegate.add_dir_action(action)

View File

@@ -0,0 +1,469 @@
from __future__ import annotations
import os
import pathlib
from typing import Any, cast
from bec_lib.logger import bec_logger
from bec_lib.macro_update_handler import has_executable_code
from qtpy.QtCore import QEvent, QTimer, Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget
import bec_widgets.widgets.containers.ads as QtAds
from bec_widgets import BECWidget
from bec_widgets.widgets.containers.ads import CDockAreaWidget, CDockWidget
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: CDockWidget | None = None
self.focused_editor.connect(self._on_last_focused_editor_changed)
self.add_editor()
self._open_files = {}
def _create_editor(self):
init_lsp = len(self.dock_manager.dockWidgets()) == 0
widget = MonacoWidget(self, init_lsp=init_lsp)
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.DockWidgetFeature.DockWidgetDeleteOnClose, True)
dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True)
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, True)
dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetFeature.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.
"""
dock_widget = self.dock_manager.focusedDockWidget()
if dock_widget is not None and isinstance(dock_widget.widget(), MonacoWidget):
self.last_focused_editor = dock_widget
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(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.DockWidgetArea.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)
if not dock_area:
return
editor_dock = dock_area.currentDockWidget()
if not editor_dock:
return
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)
if scope is not None:
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)
if scope is not None:
widget.metadata["scope"] = scope
editor_dock.setAsCurrentTab()
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.
format_on_save (bool): If True, format the code before saving if it's a Python file.
"""
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 not save_file or not save_file[0]:
return
# 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))
logger.debug(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) -> QtAds.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())

View File

@@ -1,11 +1,24 @@
from typing import Literal
from __future__ import annotations
import os
import traceback
from typing import TYPE_CHECKING, Literal
import black
import isort
import qtmonaco
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QDialog, 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
if TYPE_CHECKING:
from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog
logger = bec_logger.logger
class MonacoWidget(BECWidget, QWidget):
@@ -14,6 +27,7 @@ class MonacoWidget(BECWidget, QWidget):
"""
text_changed = Signal(str)
save_enabled = Signal(bool)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
@@ -21,6 +35,7 @@ class MonacoWidget(BECWidget, QWidget):
"get_text",
"insert_text",
"delete_line",
"open_file",
"set_language",
"get_language",
"set_theme",
@@ -37,7 +52,9 @@ class MonacoWidget(BECWidget, QWidget):
"screenshot",
]
def __init__(self, parent=None, config=None, client=None, gui_id=None, **kwargs):
def __init__(
self, parent=None, config=None, client=None, gui_id=None, init_lsp: bool = True, **kwargs
):
super().__init__(
parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs
)
@@ -47,7 +64,30 @@ 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 = {}
if init_lsp:
self.editor.update_workspace_configuration(
{
"pylsp": {
"plugins": {
"pylsp-bec": {"service_config": self.client._service_config.config}
}
}
}
)
@property
def current_file(self):
"""
Get the current file being edited.
"""
return self._current_file
def apply_theme(self, theme: str | None = None) -> None:
"""
@@ -61,14 +101,19 @@ class MonacoWidget(BECWidget, QWidget):
editor_theme = "vs" if theme == "light" else "vs-dark"
self.set_theme(editor_theme)
def set_text(self, text: str) -> None:
def set_text(self, text: str, file_name: str | None = None, reset: bool = False) -> 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
reset (bool): If True, reset the original content to the new text.
"""
self.editor.set_text(text)
self._current_file = file_name if file_name else self._current_file
if reset:
self._original_content = text
self.editor.set_text(text, uri=file_name)
def get_text(self) -> str:
"""
@@ -76,6 +121,32 @@ 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.
@@ -96,6 +167,32 @@ 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, reset=True)
@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,
@@ -213,6 +310,46 @@ 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)
self._run_dialog_and_insert_code(dialog)
def _run_dialog_and_insert_code(self, dialog: ScanControlDialog):
"""
Run the dialog and insert the generated scan code if accepted.
It is a separate method to allow easier testing.
Args:
dialog (ScanControlDialog): The scan control dialog instance.
"""
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([])
@@ -234,7 +371,7 @@ if TYPE_CHECKING:
scans: Scans
#######################################
########## User Script #####################
########## User Script ################
#######################################
# This is a comment

View File

@@ -0,0 +1,145 @@
"""
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_())

View File

@@ -1,13 +1,19 @@
import datetime
import importlib
import importlib.metadata
import os
import re
from typing import Literal
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
@@ -17,16 +23,19 @@ 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 = set()
self._sections = [] # Use list to maintain order instead of 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"]
self.sections = ["scripts", "macros"]
@SafeProperty(list)
def sections(self):
@@ -35,10 +44,16 @@ class IDEExplorer(BECWidget, QWidget):
@sections.setter
def sections(self, value):
existing_sections = set(self._sections)
self._sections = set(value)
self._update_section_visibility(self._sections - existing_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)
def _update_section_visibility(self, sections):
# sections is now an ordered list, not a set
for section in sections:
self._add_section(section)
@@ -46,15 +61,29 @@ class IDEExplorer(BECWidget, QWidget):
match section_name.lower():
case "scripts":
self.add_script_section()
case "macros":
self.add_macro_section()
case _:
pass
def _remove_section(self, section_name):
section = self.main_explorer.get_section(section_name.upper())
if section:
self.main_explorer.remove_section(section)
self._sections.remove(section_name)
def clear(self):
"""Clear all sections from the explorer."""
for section in reversed(self._sections):
self._remove_section(section)
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)
@@ -67,24 +96,98 @@ class IDEExplorer(BECWidget, QWidget):
section.set_widget(script_explorer)
self.main_explorer.add_section(section)
plugin_scripts_dir = None
plugins = importlib.metadata.entry_points(group="bec")
for plugin in plugins:
if plugin.name == "plugin_bec":
plugin = plugin.load()
plugin_scripts_dir = os.path.join(plugin.__path__[0], "scripts")
break
plugin_scripts_dir = self._get_plugin_dir("scripts")
if not plugin_scripts_dir or not os.path.exists(plugin_scripts_dir):
return
shared_script_section = CollapsibleSection(title="Shared", parent=self)
shared_script_section = CollapsibleSection(title="Shared (Read-only)", parent=self)
shared_script_section.setToolTip("Shared scripts (read-only)")
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)
# macros_section = CollapsibleSection("MACROS", indentation=0)
# macros_section.set_widget(QLabel("Macros will be implemented later"))
# self.main_explorer.add_section(macros_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)
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), convert_to_pixmap=False)
)
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 = self._get_plugin_dir("macros")
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 _get_plugin_dir(self, dir_name: Literal["scripts", "macros"]) -> str | None:
"""Get the path to the specified directory within the BEC plugin.
Returns:
The path to the specified directory, or None if not found.
"""
plugins = importlib.metadata.entry_points(group="bec")
for plugin in plugins:
if plugin.name == "plugin_bec":
plugin = plugin.load()
return os.path.join(plugin.__path__[0], dir_name)
return None
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."""
@@ -136,6 +239,134 @@ 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("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

View File

@@ -1,7 +1,7 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
@@ -20,6 +20,8 @@ class IDEExplorerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = IDEExplorer(parent)
return t

View File

@@ -0,0 +1,119 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
from unittest import mock
import pytest
from qtpy.QtCore import QMimeData, QPoint, Qt
from qtpy.QtWidgets import QLabel
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
@pytest.fixture
def collapsible_section(qtbot):
"""Create a basic CollapsibleSection widget for testing"""
widget = CollapsibleSection(title="Test Section")
qtbot.addWidget(widget)
yield widget
@pytest.fixture
def dummy_content_widget(qtbot):
"""Create a simple widget to be used as content"""
widget = QLabel("Test Content")
qtbot.addWidget(widget)
return widget
def test_basic_initialization(collapsible_section):
"""Test basic initialization"""
assert collapsible_section.title == "Test Section"
assert collapsible_section.expanded is True
assert collapsible_section.content_widget is None
def test_toggle_expanded(collapsible_section):
"""Test toggling expansion state"""
assert collapsible_section.expanded is True
collapsible_section.toggle_expanded()
assert collapsible_section.expanded is False
collapsible_section.toggle_expanded()
assert collapsible_section.expanded is True
def test_set_widget(collapsible_section, dummy_content_widget):
"""Test setting content widget"""
collapsible_section.set_widget(dummy_content_widget)
assert collapsible_section.content_widget == dummy_content_widget
assert dummy_content_widget.parent() == collapsible_section
def test_connect_add_button(qtbot):
"""Test connecting add button"""
widget = CollapsibleSection(title="Test", show_add_button=True)
qtbot.addWidget(widget)
mock_slot = mock.MagicMock()
widget.connect_add_button(mock_slot)
qtbot.mouseClick(widget.header_add_button, Qt.MouseButton.LeftButton)
mock_slot.assert_called_once()
def test_section_reorder_signal(collapsible_section):
"""Test section reorder signal emission"""
signals_received = []
collapsible_section.section_reorder_requested.connect(
lambda source, target: signals_received.append((source, target))
)
# Create mock drop event
mime_data = QMimeData()
mime_data.setText("section:Source Section")
mock_event = mock.MagicMock()
mock_event.mimeData.return_value = mime_data
collapsible_section._header_drop_event(mock_event)
assert len(signals_received) == 1
assert signals_received[0] == ("Source Section", "Test Section")
def test_nested_collapsible_sections(qtbot):
"""Test that collapsible sections can be nested"""
# Create parent section
parent_section = CollapsibleSection(title="Parent Section")
qtbot.addWidget(parent_section)
# Create child section
child_section = CollapsibleSection(title="Child Section")
qtbot.addWidget(child_section)
# Add some content to the child section
child_content = QLabel("Child Content")
qtbot.addWidget(child_content)
child_section.set_widget(child_content)
# Nest the child section inside the parent
parent_section.set_widget(child_section)
# Verify nesting structure
assert parent_section.content_widget == child_section
assert child_section.parent() == parent_section
assert child_section.content_widget == child_content
assert child_content.parent() == child_section
# Test that both sections can expand/collapse independently
assert parent_section.expanded is True
assert child_section.expanded is True
# Collapse child section
child_section.toggle_expanded()
assert child_section.expanded is False
assert parent_section.expanded is True # Parent should remain expanded
# Collapse parent section
parent_section.toggle_expanded()
assert parent_section.expanded is False
assert child_section.expanded is False # Child state unchanged

View File

@@ -0,0 +1,378 @@
"""
Unit tests for the Developer View widget.
This module tests the DeveloperView widget functionality including:
- Widget initialization and setup
- Monaco editor integration
- IDE Explorer integration
- File operations (open, save, format)
- Context menu actions
- Toolbar functionality
"""
import os
import tempfile
from unittest import mock
import pytest
from qtpy.QtWidgets import QDialog
from bec_widgets.applications.views.developer_view.developer_widget import DeveloperWidget
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
from .client_mocks import mocked_client
@pytest.fixture
def developer_view(qtbot, mocked_client):
"""Create a DeveloperWidget for testing."""
widget = DeveloperWidget(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def temp_python_file():
"""Create a temporary Python file for testing."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(
"""# Test Python file
import os
import sys
def test_function():
return "Hello, World!"
if __name__ == "__main__":
print(test_function())
"""
)
temp_file_path = f.name
yield temp_file_path
# Cleanup
if os.path.exists(temp_file_path):
os.unlink(temp_file_path)
@pytest.fixture
def mock_scan_control_dialog():
"""Mock the ScanControlDialog for testing."""
with mock.patch(
"bec_widgets.widgets.editors.monaco.scan_control_dialog.ScanControlDialog"
) as mock_dialog:
# Configure the mock dialog
mock_dialog_instance = mock.MagicMock()
mock_dialog_instance.exec_.return_value = QDialog.DialogCode.Accepted
mock_dialog_instance.get_scan_code.return_value = (
"scans.ascan(dev.samx, 0, 1, 10, exp_time=0.1)"
)
mock_dialog.return_value = mock_dialog_instance
yield mock_dialog_instance
class TestDeveloperViewInitialization:
"""Test developer view initialization and basic functionality."""
def test_developer_view_initialization(self, developer_view):
"""Test that the developer view initializes correctly."""
# Check that main components are created
assert hasattr(developer_view, "monaco")
assert hasattr(developer_view, "explorer")
assert hasattr(developer_view, "console")
assert hasattr(developer_view, "terminal")
assert hasattr(developer_view, "toolbar")
assert hasattr(developer_view, "dock_manager")
assert hasattr(developer_view, "plotting_ads")
assert hasattr(developer_view, "signature_help")
def test_monaco_editor_integration(self, developer_view):
"""Test that Monaco editor is properly integrated."""
assert isinstance(developer_view.monaco, MonacoDock)
assert developer_view.monaco.parent() is not None
def test_ide_explorer_integration(self, developer_view):
"""Test that IDE Explorer is properly integrated."""
assert isinstance(developer_view.explorer, IDEExplorer)
assert developer_view.explorer.parent() is not None
def test_toolbar_components(self, developer_view):
"""Test that toolbar components are properly set up."""
assert developer_view.toolbar is not None
# Check for expected toolbar actions
toolbar_components = developer_view.toolbar.components
expected_actions = ["save", "save_as", "run", "stop", "vim"]
for action_name in expected_actions:
assert toolbar_components.exists(action_name)
def test_dock_manager_setup(self, developer_view):
"""Test that dock manager is properly configured."""
assert developer_view.dock_manager is not None
# Check that docks are added
dock_widgets = developer_view.dock_manager.dockWidgets()
assert len(dock_widgets) >= 4 # Explorer, Monaco, Console, Terminal
class TestFileOperations:
"""Test file operation functionality."""
def test_open_new_file(self, developer_view, temp_python_file, qtbot):
"""Test opening a new file in the Monaco editor."""
# Simulate opening a file through the IDE explorer signal
developer_view._open_new_file(temp_python_file, "scripts/local")
# Wait for the file to be loaded
qtbot.waitUntil(
lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000
)
# Check that the file was opened
assert temp_python_file in developer_view.monaco._get_open_files()
# Check that content was loaded (simplified check)
# Get the editor dock for the file and check its content
dock = developer_view.monaco._get_editor_dock(temp_python_file)
if dock:
editor_widget = dock.widget()
assert "test_function" in editor_widget.get_text()
def test_open_shared_file_readonly(self, developer_view, temp_python_file, qtbot):
"""Test that shared files are opened in read-only mode."""
# Open file with shared scope
developer_view._open_new_file(temp_python_file, "scripts/shared")
qtbot.waitUntil(
lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000
)
# Check that the file is set to read-only
dock = developer_view.monaco._get_editor_dock(temp_python_file)
if dock:
monaco_widget = dock.widget()
# Check that the widget is in read-only mode
# This depends on MonacoWidget having a readonly property or method
assert monaco_widget is not None
def test_file_icon_assignment(self, developer_view, temp_python_file, qtbot):
"""Test that file icons are assigned based on scope."""
# Test script file icon
developer_view._open_new_file(temp_python_file, "scripts/local")
qtbot.waitUntil(
lambda: temp_python_file in developer_view.monaco._get_open_files(), timeout=2000
)
# Check that an icon was set (simplified check)
dock = developer_view.monaco._get_editor_dock(temp_python_file)
if dock:
assert not dock.icon().isNull()
def test_save_functionality(self, developer_view, qtbot):
"""Test the save functionality."""
# Get the currently focused editor widget (if any)
if developer_view.monaco.last_focused_editor:
editor_widget = developer_view.monaco.last_focused_editor.widget()
test_text = "print('Hello from save test')"
editor_widget.set_text(test_text)
qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000)
# Test the save action
with mock.patch.object(developer_view.monaco, "save_file") as mock_save:
developer_view.on_save()
mock_save.assert_called_once()
def test_save_as_functionality(self, developer_view, qtbot):
"""Test the save as functionality."""
# Get the currently focused editor widget (if any)
if developer_view.monaco.last_focused_editor:
editor_widget = developer_view.monaco.last_focused_editor.widget()
test_text = "print('Hello from save as test')"
editor_widget.set_text(test_text)
qtbot.waitUntil(lambda: editor_widget.get_text() == test_text, timeout=1000)
# Test the save as action
with mock.patch.object(developer_view.monaco, "save_file") as mock_save:
developer_view.on_save_as()
mock_save.assert_called_once_with(force_save_as=True)
class TestMonacoEditorIntegration:
"""Test Monaco editor specific functionality."""
def test_vim_mode_toggle(self, developer_view, qtbot):
"""Test vim mode toggle functionality."""
# Test enabling vim mode
with mock.patch.object(developer_view.monaco, "set_vim_mode") as mock_vim:
developer_view.on_vim_triggered()
# The actual call depends on the checkbox state
mock_vim.assert_called_once()
def test_context_menu_insert_scan(self, developer_view, mock_scan_control_dialog, qtbot):
"""Test the Insert Scan context menu action."""
# This functionality is handled by individual MonacoWidget instances
# Test that the dock has editor widgets
dock_widgets = developer_view.monaco.dock_manager.dockWidgets()
assert len(dock_widgets) >= 1
# Test on the first available editor
first_dock = dock_widgets[0]
monaco_widget = first_dock.widget()
assert isinstance(monaco_widget, MonacoWidget)
def test_context_menu_format_code(self, developer_view, qtbot):
"""Test the Format Code context menu action."""
# Get an editor widget from the dock manager
dock_widgets = developer_view.monaco.dock_manager.dockWidgets()
if dock_widgets:
first_dock = dock_widgets[0]
monaco_widget = first_dock.widget()
# Set some unformatted Python code
unformatted_code = "import os,sys\ndef test():\n x=1+2\n return x"
monaco_widget.set_text(unformatted_code)
qtbot.waitUntil(lambda: monaco_widget.get_text() == unformatted_code, timeout=1000)
# Test format action on the individual widget
with mock.patch.object(monaco_widget, "format") as mock_format:
monaco_widget.format()
mock_format.assert_called_once()
def test_save_enabled_signal_handling(self, developer_view, qtbot):
"""Test that save enabled signals are handled correctly."""
# Mock the toolbar update method
with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update:
# Simulate save enabled signal
developer_view.monaco.save_enabled.emit(True)
mock_update.assert_called_with(True)
developer_view.monaco.save_enabled.emit(False)
mock_update.assert_called_with(False)
class TestIDEExplorerIntegration:
"""Test IDE Explorer integration."""
def test_file_open_signal_connection(self, developer_view):
"""Test that file open signals are properly connected."""
# Test that the signal connection works by mocking the connected method
with mock.patch.object(developer_view, "_open_new_file") as mock_open:
# Emit the signal to test the connection
developer_view.explorer.file_open_requested.emit("test_file.py", "scripts/local")
mock_open.assert_called_once_with("test_file.py", "scripts/local")
def test_file_preview_signal_connection(self, developer_view):
"""Test that file preview signals are properly connected."""
# Test that the signal exists and can be emitted (basic connection test)
try:
developer_view.explorer.file_preview_requested.emit("test_file.py", "scripts/local")
# If no exception is raised, the signal exists and is connectable
assert True
except AttributeError:
assert False, "file_preview_requested signal not found"
def test_sections_configuration(self, developer_view):
"""Test that IDE Explorer sections are properly configured."""
assert "scripts" in developer_view.explorer.sections
assert "macros" in developer_view.explorer.sections
class TestToolbarIntegration:
"""Test toolbar functionality and integration."""
def test_toolbar_save_button_state(self, developer_view):
"""Test toolbar save button state management."""
# Test that save buttons exist and can be controlled
save_action = developer_view.toolbar.components.get_action("save")
save_as_action = developer_view.toolbar.components.get_action("save_as")
# Test that the actions exist and are accessible
assert save_action.action is not None
assert save_as_action.action is not None
# Test that they can be enabled/disabled via the update method
developer_view._on_save_enabled_update(False)
assert not save_action.action.isEnabled()
assert not save_as_action.action.isEnabled()
developer_view._on_save_enabled_update(True)
assert save_action.action.isEnabled()
assert save_as_action.action.isEnabled()
def test_vim_mode_button_toggle(self, developer_view, qtbot):
"""Test vim mode button toggle functionality."""
vim_action = developer_view.toolbar.components.get_action("vim")
if vim_action:
# Test toggling vim mode
initial_state = vim_action.action.isChecked()
# Simulate button click
vim_action.action.trigger()
# Check that state changed
assert vim_action.action.isChecked() != initial_state
class TestErrorHandling:
"""Test error handling in various scenarios."""
def test_invalid_scope_handling(self, developer_view, temp_python_file):
"""Test handling of invalid scope parameters."""
# Test with invalid scope
try:
developer_view._open_new_file(temp_python_file, "invalid/scope")
except Exception as e:
assert False, f"Invalid scope should be handled gracefully: {e}"
def test_monaco_editor_error_handling(self, developer_view):
"""Test error handling in Monaco editor operations."""
# Test with editor widgets from dock manager
dock_widgets = developer_view.monaco.dock_manager.dockWidgets()
if dock_widgets:
first_dock = dock_widgets[0]
monaco_widget = first_dock.widget()
# Test setting invalid text
try:
monaco_widget.set_text(None) # This might cause an error
except Exception:
# Errors should be handled gracefully
pass
class TestSignalIntegration:
"""Test signal connections and data flow."""
def test_file_open_signal_flow(self, developer_view, temp_python_file, qtbot):
"""Test the complete file open signal flow."""
# Mock the _open_new_file method to verify it gets called
with mock.patch.object(developer_view, "_open_new_file") as mock_open:
# Emit the file open signal from explorer
developer_view.explorer.file_open_requested.emit(temp_python_file, "scripts/local")
# Verify the signal was handled
mock_open.assert_called_once_with(temp_python_file, "scripts/local")
def test_save_enabled_signal_flow(self, developer_view, qtbot):
"""Test the save enabled signal flow."""
# Mock the update method (the actual method is _on_save_enabled_update)
with mock.patch.object(developer_view, "_on_save_enabled_update") as mock_update:
# Simulate monaco dock emitting save enabled signal
developer_view.monaco.save_enabled.emit(True)
# Verify the signal was handled
mock_update.assert_called_once_with(True)
if __name__ == "__main__":
pytest.main([__file__])

View File

@@ -1,7 +1,9 @@
import os
from pathlib import Path
from unittest import mock
import pytest
from qtpy.QtWidgets import QMessageBox
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
@@ -34,3 +36,423 @@ def test_ide_explorer_add_local_script(ide_explorer, qtbot, tmpdir):
):
ide_explorer._add_local_script()
assert os.path.exists(os.path.join(tmpdir, "test_file.py"))
def test_shared_scripts_section_with_files(ide_explorer, tmpdir):
"""Test that shared scripts section is created when plugin directory has files"""
# Create dummy shared script files
shared_scripts_dir = tmpdir.mkdir("shared_scripts")
shared_scripts_dir.join("shared_script1.py").write("# Shared script 1")
shared_scripts_dir.join("shared_script2.py").write("# Shared script 2")
ide_explorer.clear()
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = str(shared_scripts_dir)
ide_explorer.add_script_section()
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert scripts_section is not None
# Should have both Local and Shared sections
local_section = scripts_section.content_widget.get_section("Local")
shared_section = scripts_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is not None
assert "read-only" in shared_section.toolTip().lower()
def test_shared_macros_section_with_files(ide_explorer, tmpdir):
"""Test that shared macros section is created when plugin directory has files"""
# Create dummy shared macro files
shared_macros_dir = tmpdir.mkdir("shared_macros")
shared_macros_dir.join("shared_macro1.py").write(
"""
def shared_function1():
return "shared1"
def shared_function2():
return "shared2"
"""
)
shared_macros_dir.join("utilities.py").write(
"""
def utility_function():
return "utility"
"""
)
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = str(shared_macros_dir)
ide_explorer.clear()
ide_explorer.sections = ["macros"]
macros_section = ide_explorer.main_explorer.get_section("MACROS")
assert macros_section is not None
# Should have both Local and Shared sections
local_section = macros_section.content_widget.get_section("Local")
shared_section = macros_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is not None
assert "read-only" in shared_section.toolTip().lower()
def test_shared_sections_not_added_when_plugin_dir_missing(ide_explorer):
"""Test that shared sections are not added when plugin directories don't exist"""
ide_explorer.clear()
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = None
ide_explorer.add_script_section()
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert scripts_section is not None
# Should only have Local section
local_section = scripts_section.content_widget.get_section("Local")
shared_section = scripts_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is None
def test_shared_sections_not_added_when_directory_empty(ide_explorer, tmpdir):
"""Test that shared sections are not added when plugin directory doesn't exist on disk"""
ide_explorer.clear()
# Return a path that doesn't exist
nonexistent_path = str(tmpdir.join("nonexistent"))
with mock.patch.object(ide_explorer, "_get_plugin_dir") as mock_get_plugin_dir:
mock_get_plugin_dir.return_value = nonexistent_path
ide_explorer.add_script_section()
scripts_section = ide_explorer.main_explorer.get_section("SCRIPTS")
assert scripts_section is not None
# Should only have Local section since directory doesn't exist
local_section = scripts_section.content_widget.get_section("Local")
shared_section = scripts_section.content_widget.get_section("Shared (Read-only)")
assert local_section is not None
assert shared_section is None
@pytest.mark.parametrize(
"slot, signal, file_name,scope",
[
(
"_emit_file_open_scripts_local",
"file_open_requested",
"example_script.py",
"scripts/local",
),
(
"_emit_file_preview_scripts_local",
"file_preview_requested",
"example_macro.py",
"scripts/local",
),
(
"_emit_file_open_scripts_shared",
"file_open_requested",
"example_script.py",
"scripts/shared",
),
(
"_emit_file_preview_scripts_shared",
"file_preview_requested",
"example_macro.py",
"scripts/shared",
),
],
)
def test_ide_explorer_file_signals(ide_explorer, qtbot, slot, signal, file_name, scope):
"""Test that the correct signals are emitted when files are opened or previewed"""
recv = []
def recv_file_signal(file_name, scope):
recv.append((file_name, scope))
sig = getattr(ide_explorer, signal)
sig.connect(recv_file_signal)
# Call the appropriate slot
getattr(ide_explorer, slot)(file_name)
qtbot.wait(300)
# Verify the signal was emitted with correct arguments
assert recv == [(file_name, scope)]
@pytest.mark.parametrize(
"slot, signal, func_name, file_path,scope",
[
(
"_emit_file_open_macros_local",
"file_open_requested",
"example_macro_function",
"macros/local/example_macro.py",
"macros/local",
),
(
"_emit_file_preview_macros_local",
"file_preview_requested",
"example_macro_function",
"macros/local/example_macro.py",
"macros/local",
),
(
"_emit_file_open_macros_shared",
"file_open_requested",
"example_macro_function",
"macros/shared/example_macro.py",
"macros/shared",
),
(
"_emit_file_preview_macros_shared",
"file_preview_requested",
"example_macro_function",
"macros/shared/example_macro.py",
"macros/shared",
),
],
)
def test_ide_explorer_file_signals_macros(
ide_explorer, qtbot, slot, signal, func_name, file_path, scope
):
"""Test that the correct signals are emitted when macro files are opened or previewed"""
recv = []
def recv_file_signal(file_name, scope):
recv.append((file_name, scope))
sig = getattr(ide_explorer, signal)
sig.connect(recv_file_signal)
# Call the appropriate slot
getattr(ide_explorer, slot)(func_name, file_path)
qtbot.wait(300)
# Verify the signal was emitted with correct arguments
assert recv == [(file_path, scope)]
def test_ide_explorer_add_local_macro(ide_explorer, qtbot, tmpdir):
"""Test adding a local macro through the UI"""
# Create macros section first
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Set up the local macro directory
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("test_macro_function", True),
):
ide_explorer._add_local_macro()
# Check that the macro file was created
expected_file = os.path.join(tmpdir, "test_macro_function.py")
assert os.path.exists(expected_file)
# Check that the file contains the expected function
with open(expected_file, "r") as f:
content = f.read()
assert "def test_macro_function():" in content
assert "test_macro_function macro" in content
def test_ide_explorer_add_local_macro_invalid_name(ide_explorer, qtbot, tmpdir):
"""Test adding a local macro with invalid function name"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Test with invalid function name (starts with number)
with (
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("123invalid", True),
),
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.warning"
) as mock_warning,
):
ide_explorer._add_local_macro()
# Should show warning message
mock_warning.assert_called_once()
# Should not create any file
assert len(os.listdir(tmpdir)) == 0
def test_ide_explorer_add_local_macro_file_exists(ide_explorer, qtbot, tmpdir):
"""Test adding a local macro when file already exists"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Create an existing file
existing_file = Path(tmpdir) / "existing_macro.py"
existing_file.write_text("# Existing macro")
with (
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("existing_macro", True),
),
mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.question",
return_value=QMessageBox.StandardButton.Yes,
) as mock_question,
):
ide_explorer._add_local_macro()
# Should ask for overwrite confirmation
mock_question.assert_called_once()
# File should be overwritten with new content
with open(existing_file, "r") as f:
content = f.read()
assert "def existing_macro():" in content
def test_ide_explorer_add_local_macro_cancelled(ide_explorer, qtbot, tmpdir):
"""Test cancelling the add local macro dialog"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# User cancels the dialog
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QInputDialog.getText",
return_value=("", False), # User cancelled
):
ide_explorer._add_local_macro()
# Should not create any file
assert len(os.listdir(tmpdir)) == 0
def test_ide_explorer_reload_macros_success(ide_explorer, qtbot):
"""Test successful macro reloading"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Mock the client and macros
mock_client = mock.MagicMock()
mock_macros = mock.MagicMock()
mock_client.macros = mock_macros
ide_explorer.client = mock_client
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.information"
) as mock_info:
ide_explorer._reload_macros()
# Should call load_all_user_macros
mock_macros.load_all_user_macros.assert_called_once()
# Should show success message
mock_info.assert_called_once()
assert "successfully" in mock_info.call_args[0][2]
def test_ide_explorer_reload_macros_error(ide_explorer, qtbot):
"""Test macro reloading when an error occurs"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Mock client with macros that raises an exception
mock_client = mock.MagicMock()
mock_macros = mock.MagicMock()
mock_macros.load_all_user_macros.side_effect = Exception("Test error")
mock_client.macros = mock_macros
ide_explorer.client = mock_client
with mock.patch(
"bec_widgets.widgets.utility.ide_explorer.ide_explorer.QMessageBox.critical"
) as mock_critical:
ide_explorer._reload_macros()
# Should show error message
mock_critical.assert_called_once()
assert "Failed to reload macros" in mock_critical.call_args[0][2]
def test_ide_explorer_refresh_macro_file_local(ide_explorer, qtbot, tmpdir):
"""Test refreshing a local macro file"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Set up the local macro directory
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Create a test macro file
macro_file = Path(tmpdir) / "test_macro.py"
macro_file.write_text("def test_function(): pass")
# Mock the refresh_file_item method
with mock.patch.object(
local_macros_section.content_widget, "refresh_file_item"
) as mock_refresh:
ide_explorer.refresh_macro_file(str(macro_file))
# Should call refresh_file_item with the file path
mock_refresh.assert_called_once_with(str(macro_file))
def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir):
"""Test refreshing a macro file that doesn't match any directory"""
ide_explorer.clear()
ide_explorer.sections = ["macros"]
# Set up the local macro directory
local_macros_section = ide_explorer.main_explorer.get_section(
"MACROS"
).content_widget.get_section("Local")
local_macros_section.content_widget.set_directory(str(tmpdir))
# Try to refresh a file that's not in any macro directory
unrelated_file = "/some/other/path/unrelated.py"
# Mock the refresh_file_item method
with mock.patch.object(
local_macros_section.content_widget, "refresh_file_item"
) as mock_refresh:
ide_explorer.refresh_macro_file(unrelated_file)
# Should not call refresh_file_item
mock_refresh.assert_not_called()
def test_ide_explorer_refresh_macro_file_no_sections(ide_explorer, qtbot):
"""Test refreshing a macro file when no macro sections exist"""
ide_explorer.clear()
# Don't add macros section
# Should handle gracefully without error
ide_explorer.refresh_macro_file("/some/path/test.py")
# Test passes if no exception is raised

View File

@@ -0,0 +1,548 @@
"""
Unit tests for the MacroTreeWidget.
"""
from pathlib import Path
import pytest
from qtpy.QtCore import QEvent, QModelIndex, Qt
from qtpy.QtGui import QMouseEvent
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget
@pytest.fixture
def temp_macro_files(tmpdir):
"""Create temporary macro files for testing."""
macro_dir = Path(tmpdir) / "macros"
macro_dir.mkdir()
# Create a simple macro file with functions
macro_file1 = macro_dir / "test_macros.py"
macro_file1.write_text(
'''
def test_macro_function():
"""A test macro function."""
return "test"
def another_function(param1, param2):
"""Another function with parameters."""
return param1 + param2
class TestClass:
"""This class should be ignored."""
def method(self):
pass
'''
)
# Create another macro file
macro_file2 = macro_dir / "utils_macros.py"
macro_file2.write_text(
'''
def utility_function():
"""A utility function."""
pass
def deprecated_function():
"""Old function."""
return None
'''
)
# Create a file with no functions (should be ignored)
empty_file = macro_dir / "empty.py"
empty_file.write_text(
"""
# Just a comment
x = 1
y = 2
"""
)
# Create a file starting with underscore (should be ignored)
private_file = macro_dir / "_private.py"
private_file.write_text(
"""
def private_function():
return "private"
"""
)
# Create a file with syntax errors
error_file = macro_dir / "error_file.py"
error_file.write_text(
"""
def broken_function(
# Missing closing parenthesis and colon
pass
"""
)
return macro_dir
@pytest.fixture
def macro_tree(qtbot, temp_macro_files):
"""Create a MacroTreeWidget with test macro files."""
widget = MacroTreeWidget()
widget.set_directory(str(temp_macro_files))
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
class TestMacroTreeWidgetInitialization:
"""Test macro tree widget initialization and basic functionality."""
def test_initialization(self, qtbot):
"""Test that the macro tree widget initializes correctly."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
# Check basic properties
assert widget.tree is not None
assert widget.model is not None
assert widget.delegate is not None
assert widget.directory is None
# Check that tree is configured properly
assert widget.tree.isHeaderHidden()
assert widget.tree.rootIsDecorated()
assert not widget.tree.editTriggers()
def test_set_directory_with_valid_path(self, macro_tree, temp_macro_files):
"""Test setting a valid directory path."""
assert macro_tree.directory == str(temp_macro_files)
# Check that files were loaded
assert macro_tree.model.rowCount() > 0
# Should have 2 files (test_macros.py and utils_macros.py)
# empty.py and _private.py should be filtered out
expected_files = ["test_macros", "utils_macros"]
actual_files = []
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
actual_files.append(item.text())
# Sort for consistent comparison
actual_files.sort()
expected_files.sort()
for expected in expected_files:
assert expected in actual_files
def test_set_directory_with_invalid_path(self, qtbot):
"""Test setting an invalid directory path."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
widget.set_directory("/nonexistent/path")
# Should handle gracefully
assert widget.directory == "/nonexistent/path"
assert widget.model.rowCount() == 0
def test_set_directory_with_none(self, qtbot):
"""Test setting directory to None."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
widget.set_directory(None)
# Should handle gracefully
assert widget.directory is None
assert widget.model.rowCount() == 0
class TestMacroFunctionParsing:
"""Test macro function parsing and AST functionality."""
def test_extract_functions_from_file(self, macro_tree, temp_macro_files):
"""Test extracting functions from a Python file."""
test_file = temp_macro_files / "test_macros.py"
functions = macro_tree._extract_functions_from_file(test_file)
# Should extract 2 functions, not the class method
assert len(functions) == 2
assert "test_macro_function" in functions
assert "another_function" in functions
assert "method" not in functions # Class methods should be excluded
# Check function details
test_func = functions["test_macro_function"]
assert test_func["line_number"] == 2 # First function starts at line 2
assert "A test macro function" in test_func["docstring"]
def test_extract_functions_from_empty_file(self, macro_tree, temp_macro_files):
"""Test extracting functions from a file with no functions."""
empty_file = temp_macro_files / "empty.py"
functions = macro_tree._extract_functions_from_file(empty_file)
assert len(functions) == 0
def test_extract_functions_from_invalid_file(self, macro_tree):
"""Test extracting functions from a non-existent file."""
nonexistent_file = Path("/nonexistent/file.py")
functions = macro_tree._extract_functions_from_file(nonexistent_file)
assert len(functions) == 0
def test_extract_functions_from_syntax_error_file(self, macro_tree, temp_macro_files):
"""Test extracting functions from a file with syntax errors."""
error_file = temp_macro_files / "error_file.py"
functions = macro_tree._extract_functions_from_file(error_file)
# Should return empty dict on syntax error
assert len(functions) == 0
def test_create_file_item(self, macro_tree, temp_macro_files):
"""Test creating a file item from a Python file."""
test_file = temp_macro_files / "test_macros.py"
file_item = macro_tree._create_file_item(test_file)
assert file_item is not None
assert file_item.text() == "test_macros"
assert file_item.rowCount() == 2 # Should have 2 function children
# Check file data
file_data = file_item.data(Qt.ItemDataRole.UserRole)
assert file_data["type"] == "file"
assert file_data["file_path"] == str(test_file)
# Check function children
func_names = []
for row in range(file_item.rowCount()):
child = file_item.child(row)
func_names.append(child.text())
# Check function data
func_data = child.data(Qt.ItemDataRole.UserRole)
assert func_data["type"] == "function"
assert func_data["file_path"] == str(test_file)
assert "function_name" in func_data
assert "line_number" in func_data
assert "test_macro_function" in func_names
assert "another_function" in func_names
def test_create_file_item_with_private_file(self, macro_tree, temp_macro_files):
"""Test that files starting with underscore are ignored."""
private_file = temp_macro_files / "_private.py"
file_item = macro_tree._create_file_item(private_file)
assert file_item is None
def test_create_file_item_with_no_functions(self, macro_tree, temp_macro_files):
"""Test that files with no functions return None."""
empty_file = temp_macro_files / "empty.py"
file_item = macro_tree._create_file_item(empty_file)
assert file_item is None
class TestMacroTreeInteractions:
"""Test macro tree widget interactions and signals."""
def test_item_click_on_function(self, macro_tree, qtbot):
"""Test clicking on a function item."""
# Set up signal spy
macro_selected_signals = []
def on_macro_selected(function_name, file_path):
macro_selected_signals.append((function_name, file_path))
macro_tree.macro_selected.connect(on_macro_selected)
# Find a function item
file_item = macro_tree.model.item(0) # First file
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0) # First function
func_index = func_item.index()
# Simulate click
macro_tree._on_item_clicked(func_index)
# Check signal was emitted
assert len(macro_selected_signals) == 1
function_name, file_path = macro_selected_signals[0]
assert function_name is not None
assert file_path is not None
assert file_path.endswith(".py")
def test_item_click_on_file(self, macro_tree, qtbot):
"""Test clicking on a file item (should not emit signal)."""
# Set up signal spy
macro_selected_signals = []
def on_macro_selected(function_name, file_path):
macro_selected_signals.append((function_name, file_path))
macro_tree.macro_selected.connect(on_macro_selected)
# Find a file item
file_item = macro_tree.model.item(0)
if file_item:
file_index = file_item.index()
# Simulate click
macro_tree._on_item_clicked(file_index)
# Should not emit signal for file items
assert len(macro_selected_signals) == 0
def test_item_double_click_on_function(self, macro_tree, qtbot):
"""Test double-clicking on a function item."""
# Set up signal spy
open_requested_signals = []
def on_macro_open_requested(function_name, file_path):
open_requested_signals.append((function_name, file_path))
macro_tree.macro_open_requested.connect(on_macro_open_requested)
# Find a function item
file_item = macro_tree.model.item(0)
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0)
func_index = func_item.index()
# Simulate double-click
macro_tree._on_item_double_clicked(func_index)
# Check signal was emitted
assert len(open_requested_signals) == 1
function_name, file_path = open_requested_signals[0]
assert function_name is not None
assert file_path is not None
def test_hover_events(self, macro_tree, qtbot):
"""Test mouse hover events and action button visibility."""
# Get the tree view and its viewport
tree_view = macro_tree.tree
viewport = tree_view.viewport()
# Initially, no item should be hovered
assert not macro_tree.delegate.hovered_index.isValid()
# Find a function item to hover over
file_item = macro_tree.model.item(0)
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0)
func_index = func_item.index()
# Get the position of the function item
rect = tree_view.visualRect(func_index)
pos = rect.center()
# Simulate a mouse move event over the item
mouse_event = QMouseEvent(
QEvent.Type.MouseMove,
pos,
tree_view.mapToGlobal(pos),
Qt.MouseButton.NoButton,
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
)
# Send the event to the viewport
macro_tree.eventFilter(viewport, mouse_event)
qtbot.wait(100)
# Now, the hover index should be set
assert macro_tree.delegate.hovered_index.isValid()
assert macro_tree.delegate.hovered_index == func_index
# Simulate mouse leaving the viewport
leave_event = QEvent(QEvent.Type.Leave)
macro_tree.eventFilter(viewport, leave_event)
qtbot.wait(100)
# After leaving, no item should be hovered
assert not macro_tree.delegate.hovered_index.isValid()
def test_macro_open_action(self, macro_tree, qtbot):
"""Test the macro open action functionality."""
# Set up signal spy
open_requested_signals = []
def on_macro_open_requested(function_name, file_path):
open_requested_signals.append((function_name, file_path))
macro_tree.macro_open_requested.connect(on_macro_open_requested)
# Find a function item and set it as hovered
file_item = macro_tree.model.item(0)
if file_item and file_item.rowCount() > 0:
func_item = file_item.child(0)
func_index = func_item.index()
# Set the delegate's hovered index and current macro info
macro_tree.delegate.set_hovered_index(func_index)
func_data = func_item.data(Qt.ItemDataRole.UserRole)
macro_tree.delegate.current_macro_info = func_data
# Trigger the open action
macro_tree._on_macro_open_requested()
# Check signal was emitted
assert len(open_requested_signals) == 1
function_name, file_path = open_requested_signals[0]
assert function_name is not None
assert file_path is not None
class TestMacroTreeRefresh:
"""Test macro tree refresh functionality."""
def test_refresh(self, macro_tree, temp_macro_files):
"""Test refreshing the entire tree."""
# Get initial count
initial_count = macro_tree.model.rowCount()
# Add a new macro file
new_file = temp_macro_files / "new_macros.py"
new_file.write_text(
'''
def new_function():
"""A new function."""
return "new"
'''
)
# Refresh the tree
macro_tree.refresh()
# Should have one more file
assert macro_tree.model.rowCount() == initial_count + 1
def test_refresh_file_item(self, macro_tree, temp_macro_files):
"""Test refreshing a single file item."""
# Find the test_macros.py file
test_file_path = str(temp_macro_files / "test_macros.py")
# Get initial function count
initial_functions = []
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
item_data = item.data(Qt.ItemDataRole.UserRole)
if item_data and item_data.get("file_path") == test_file_path:
for child_row in range(item.rowCount()):
child = item.child(child_row)
initial_functions.append(child.text())
break
# Modify the file to add a new function
with open(test_file_path, "a") as f:
f.write(
'''
def newly_added_function():
"""A newly added function."""
return "added"
'''
)
# Refresh just this file
macro_tree.refresh_file_item(test_file_path)
# Check that the new function was added
updated_functions = []
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
item_data = item.data(Qt.ItemDataRole.UserRole)
if item_data and item_data.get("file_path") == test_file_path:
for child_row in range(item.rowCount()):
child = item.child(child_row)
updated_functions.append(child.text())
break
# Should have the new function
assert len(updated_functions) == len(initial_functions) + 1
assert "newly_added_function" in updated_functions
def test_refresh_nonexistent_file(self, macro_tree):
"""Test refreshing a non-existent file."""
# Should handle gracefully without crashing
macro_tree.refresh_file_item("/nonexistent/file.py")
# Tree should remain unchanged
assert macro_tree.model.rowCount() >= 0 # Just ensure it doesn't crash
def test_expand_collapse_all(self, macro_tree, qtbot):
"""Test expand/collapse all functionality."""
# Initially should be expanded
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item:
# Items with children should be expanded after initial load
if item.rowCount() > 0:
assert macro_tree.tree.isExpanded(item.index())
# Collapse all
macro_tree.collapse_all()
qtbot.wait(50)
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item and item.rowCount() > 0:
assert not macro_tree.tree.isExpanded(item.index())
# Expand all
macro_tree.expand_all()
qtbot.wait(50)
for row in range(macro_tree.model.rowCount()):
item = macro_tree.model.item(row)
if item and item.rowCount() > 0:
assert macro_tree.tree.isExpanded(item.index())
class TestMacroItemDelegate:
"""Test the custom macro item delegate functionality."""
def test_delegate_action_management(self, qtbot):
"""Test adding and clearing delegate actions."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
# Should have at least one default action (open)
assert len(widget.delegate.macro_actions) >= 1
# Add a custom action
custom_action = MaterialIconAction(icon_name="edit", tooltip="Edit", parent=widget)
widget.add_macro_action(custom_action.action)
# Should have the additional action
assert len(widget.delegate.macro_actions) >= 2
# Clear actions
widget.clear_actions()
# Should be empty
assert len(widget.delegate.macro_actions) == 0
def test_delegate_hover_index_management(self, qtbot):
"""Test hover index management in the delegate."""
widget = MacroTreeWidget()
qtbot.addWidget(widget)
# Initially no hover
assert not widget.delegate.hovered_index.isValid()
# Create a fake index
fake_index = widget.model.createIndex(0, 0)
# Set hover
widget.delegate.set_hovered_index(fake_index)
assert widget.delegate.hovered_index == fake_index
# Clear hover
widget.delegate.set_hovered_index(QModelIndex())
assert not widget.delegate.hovered_index.isValid()

View File

@@ -0,0 +1,425 @@
import os
from typing import Generator
from unittest import mock
import pytest
from qtpy.QtWidgets import QFileDialog, QMessageBox
from bec_widgets.widgets.editors.monaco.monaco_dock import MonacoDock
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from .client_mocks import mocked_client
@pytest.fixture
def monaco_dock(qtbot, mocked_client) -> Generator[MonacoDock, None, None]:
"""Create a MonacoDock for testing."""
# Mock the macros functionality
mocked_client.macros = mock.MagicMock()
mocked_client.macros._update_handler = mock.MagicMock()
mocked_client.macros._update_handler.get_macros_from_file.return_value = {}
mocked_client.macros._update_handler.get_existing_macros.return_value = {}
widget = MonacoDock(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
class TestFocusEditor:
def test_last_focused_editor_initial_none(self, monaco_dock: MonacoDock):
"""Test that last_focused_editor is initially None."""
assert monaco_dock.last_focused_editor is not None
def test_set_last_focused_editor(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test setting last_focused_editor when an editor is focused."""
file_path = tmpdir.join("test.py")
file_path.write("print('Hello, World!')")
monaco_dock.open_file(str(file_path))
qtbot.wait(300) # Wait for the editor to be fully set up
assert monaco_dock.last_focused_editor is not None
def test_last_focused_editor_updates_on_focus_change(
self, qtbot, monaco_dock: MonacoDock, tmpdir
):
"""Test that last_focused_editor updates when focus changes."""
file1 = tmpdir.join("file1.py")
file1.write("print('File 1')")
file2 = tmpdir.join("file2.py")
file2.write("print('File 2')")
monaco_dock.open_file(str(file1))
qtbot.wait(300)
editor1 = monaco_dock.last_focused_editor
monaco_dock.open_file(str(file2))
qtbot.wait(300)
editor2 = monaco_dock.last_focused_editor
assert editor1 != editor2
assert editor2 is not None
def test_opening_existing_file_updates_focus(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test that opening an already open file simply switches focus to it."""
file1 = tmpdir.join("file1.py")
file1.write("print('File 1')")
file2 = tmpdir.join("file2.py")
file2.write("print('File 2')")
monaco_dock.open_file(str(file1))
qtbot.wait(300)
editor1 = monaco_dock.last_focused_editor
monaco_dock.open_file(str(file2))
qtbot.wait(300)
editor2 = monaco_dock.last_focused_editor
# Re-open file1
monaco_dock.open_file(str(file1))
qtbot.wait(300)
editor1_again = monaco_dock.last_focused_editor
assert editor1 == editor1_again
assert editor1 != editor2
assert editor2 is not None
class TestSaveFiles:
def test_save_file_existing_file_no_macros(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test saving an existing file that is not a macro."""
# Create a test file
file_path = tmpdir.join("test.py")
file_path.write("print('Hello, World!')")
# Open file in Monaco dock
monaco_dock.open_file(str(file_path))
qtbot.wait(300)
# Get the editor widget and modify content
editor_widget = monaco_dock.last_focused_editor.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("print('Modified content')")
qtbot.wait(100)
# Verify the editor is marked as modified
assert editor_widget.modified
# Save the file
with mock.patch(
"bec_widgets.widgets.editors.monaco.monaco_dock.QFileDialog.getSaveFileName"
) as mock_dialog:
mock_dialog.return_value = (str(file_path), "Python files (*.py)")
monaco_dock.save_file()
qtbot.wait(100)
# Verify file was saved
saved_content = file_path.read()
assert saved_content == 'print("Modified content")\n'
# Verify editor is no longer marked as modified
assert not editor_widget.modified
def test_save_file_with_macros_scope(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test saving a file with macros scope updates macro handler."""
# Create a test file
file_path = tmpdir.join("test_macro.py")
file_path.write("def test_function(): pass")
# Open file in Monaco dock with macros scope
monaco_dock.open_file(str(file_path), scope="macros")
qtbot.wait(300)
# Get the editor widget and modify content
editor_widget = monaco_dock.last_focused_editor.widget()
editor_widget.set_text("def modified_function(): pass")
qtbot.wait(100)
# Mock macro validation to return True (valid)
with mock.patch.object(monaco_dock, "_validate_macros", return_value=True):
# Mock file dialog to avoid opening actual dialog (file already exists)
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(file_path), "") # User cancels
# Save the file (should save to existing file, not open dialog)
monaco_dock.save_file()
qtbot.wait(100)
# Verify macro update methods were called
monaco_dock.client.macros._update_handler.get_macros_from_file.assert_called_with(
str(file_path)
)
monaco_dock.client.macros._update_handler.get_existing_macros.assert_called_with(
str(file_path)
)
def test_save_file_invalid_macro_content(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test saving a macro file with invalid content shows warning."""
# Create a test file
file_path = tmpdir.join("test_macro.py")
file_path.write("def test_function(): pass")
# Open file in Monaco dock with macros scope
monaco_dock.open_file(str(file_path), scope="macros")
qtbot.wait(300)
# Get the editor widget and modify content to invalid macro
editor_widget = monaco_dock.last_focused_editor.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("exec('print(hello)')") # Invalid macro content
qtbot.wait(100)
# Mock QMessageBox to capture warning
with mock.patch(
"bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.warning"
) as mock_warning:
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(file_path), "")
# Save the file
monaco_dock.save_file()
qtbot.wait(100)
# Verify validation was called and warning was shown
mock_warning.assert_called_once()
# Verify file was not saved (content should remain original)
saved_content = file_path.read()
assert saved_content == "def test_function(): pass"
def test_save_file_as_new_file(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test Save As functionality creates a new file."""
# Create initial content in editor
editor_dock = monaco_dock.add_editor()
editor_widget = editor_dock.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("print('New file content')")
qtbot.wait(100)
# Mock QFileDialog.getSaveFileName
new_file_path = str(tmpdir.join("new_file.py"))
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (new_file_path, "Python files (*.py)")
# Save as new file
monaco_dock.save_file(force_save_as=True)
qtbot.wait(100)
# Verify new file was created
assert os.path.exists(new_file_path)
with open(new_file_path, "r", encoding="utf-8") as f:
content = f.read()
assert content == 'print("New file content")\n'
# Verify editor is no longer marked as modified
assert not editor_widget.modified
# Verify current_file was updated
assert editor_widget.current_file == new_file_path
def test_save_file_as_adds_py_extension(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test Save As automatically adds .py extension if none provided."""
# Create initial content in editor
editor_dock = monaco_dock.add_editor()
editor_widget = editor_dock.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("print('Test content')")
qtbot.wait(100)
# Mock QFileDialog.getSaveFileName to return path without extension
file_path_no_ext = str(tmpdir.join("test_file"))
expected_path = file_path_no_ext + ".py"
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (file_path_no_ext, "All files (*)")
# Save as new file
monaco_dock.save_file(force_save_as=True)
qtbot.wait(100)
# Verify file was created with .py extension
assert os.path.exists(expected_path)
assert editor_widget.current_file == expected_path
def test_save_file_no_focused_editor(self, monaco_dock: MonacoDock):
"""Test save_file handles case when no editor is focused."""
# Set last_focused_editor to None
with mock.patch.object(monaco_dock.last_focused_editor, "widget", return_value=None):
# Attempt to save should not raise exception
monaco_dock.save_file()
def test_save_file_emits_macro_file_updated_signal(self, qtbot, monaco_dock, tmpdir):
"""Test that macro_file_updated signal is emitted when saving macro files."""
# Create a test file
file_path = tmpdir.join("test_macro.py")
file_path.write("def test_function(): pass")
# Open file in Monaco dock with macros scope
monaco_dock.open_file(str(file_path), scope="macros")
qtbot.wait(300)
# Get the editor widget and modify content
editor_widget = monaco_dock.last_focused_editor.widget()
editor_widget.set_text("def modified_function(): pass")
qtbot.wait(100)
# Connect signal to capture emission
signal_emitted = []
monaco_dock.macro_file_updated.connect(lambda path: signal_emitted.append(path))
# Mock file dialog to avoid opening actual dialog (file already exists)
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(file_path), "")
# Save the file
monaco_dock.save_file()
qtbot.wait(100)
# Verify signal was emitted
assert len(signal_emitted) == 1
assert signal_emitted[0] == str(file_path)
def test_close_dock_asks_to_save_modified_file(self, qtbot, monaco_dock: MonacoDock, tmpdir):
"""Test that closing a modified file dock asks to save changes."""
# Create a test file
file_path = tmpdir.join("test.py")
file_path.write("print('Hello, World!')")
# Open file in Monaco dock
monaco_dock.open_file(str(file_path))
qtbot.wait(300)
# Get the editor widget and modify content
editor_widget = monaco_dock.last_focused_editor.widget()
assert isinstance(editor_widget, MonacoWidget)
editor_widget.set_text("print('Modified content')")
qtbot.wait(100)
# Mock QMessageBox to simulate user clicking 'Save'
with mock.patch(
"bec_widgets.widgets.editors.monaco.monaco_dock.QMessageBox.question"
) as mock_question:
mock_question.return_value = QMessageBox.StandardButton.Yes
# Mock QFileDialog.getSaveFileName
with mock.patch.object(QFileDialog, "getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(file_path), "Python files (*.py)")
# Close the dock; sadly, calling close() alone does not trigger the closeRequested signal
# It is only triggered if the mouse is on top of the tab close button, so we directly call the handler
monaco_dock._on_editor_close_requested(
monaco_dock.last_focused_editor, editor_widget
)
qtbot.wait(100)
# Verify file was saved
saved_content = file_path.read()
assert saved_content == 'print("Modified content")\n'
class TestSignatureHelp:
def test_signature_help_signal_emission(self, qtbot, monaco_dock: MonacoDock):
"""Test that signature help signal is emitted correctly."""
# Connect signal to capture emission
signature_emitted = []
monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig))
# Create mock signature data
signature_data = {
"signatures": [
{
"label": "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)",
"documentation": {
"value": "Print objects to the text stream file, separated by sep and followed by end."
},
}
],
"activeSignature": 0,
"activeParameter": 0,
}
# Trigger signature change
monaco_dock._on_signature_change(signature_data)
qtbot.wait(100)
# Verify signal was emitted with correct markdown format
assert len(signature_emitted) == 1
emitted_signature = signature_emitted[0]
assert "```python" in emitted_signature
assert "print(value, sep=' ', end='\\n', file=sys.stdout, flush=False)" in emitted_signature
assert "Print objects to the text stream file" in emitted_signature
def test_signature_help_empty_signatures(self, qtbot, monaco_dock: MonacoDock):
"""Test signature help with empty signatures."""
# Connect signal to capture emission
signature_emitted = []
monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig))
# Create mock signature data with no signatures
signature_data = {"signatures": []}
# Trigger signature change
monaco_dock._on_signature_change(signature_data)
qtbot.wait(100)
# Verify empty string was emitted
assert len(signature_emitted) == 1
assert signature_emitted[0] == ""
def test_signature_help_no_documentation(self, qtbot, monaco_dock: MonacoDock):
"""Test signature help when documentation is missing."""
# Connect signal to capture emission
signature_emitted = []
monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig))
# Create mock signature data without documentation
signature_data = {"signatures": [{"label": "function_name(param)"}], "activeSignature": 0}
# Trigger signature change
monaco_dock._on_signature_change(signature_data)
qtbot.wait(100)
# Verify signal was emitted with just the function signature
assert len(signature_emitted) == 1
emitted_signature = signature_emitted[0]
assert "```python" in emitted_signature
assert "function_name(param)" in emitted_signature
def test_signature_help_string_documentation(self, qtbot, monaco_dock: MonacoDock):
"""Test signature help when documentation is a string instead of dict."""
# Connect signal to capture emission
signature_emitted = []
monaco_dock.signature_help.connect(lambda sig: signature_emitted.append(sig))
# Create mock signature data with string documentation
signature_data = {
"signatures": [
{"label": "function_name(param)", "documentation": "Simple string documentation"}
],
"activeSignature": 0,
}
# Trigger signature change
monaco_dock._on_signature_change(signature_data)
qtbot.wait(100)
# Verify signal was emitted with correct format
assert len(signature_emitted) == 1
emitted_signature = signature_emitted[0]
assert "```python" in emitted_signature
assert "function_name(param)" in emitted_signature
assert "Simple string documentation" in emitted_signature
def test_signature_help_connected_to_editor(self, qtbot, monaco_dock: MonacoDock):
"""Test that signature help is connected when creating new editors."""
# Create a new editor
editor_dock = monaco_dock.add_editor()
editor_widget = editor_dock.widget()
# Verify the signal connection exists by checking connected signals
# We do this by mocking the signal and verifying the connection
with mock.patch.object(monaco_dock, "_on_signature_change") as mock_handler:
# Simulate signature help trigger from the editor
editor_widget.editor.signature_help_triggered.emit({"signatures": []})
qtbot.wait(100)
# Verify the handler was called
mock_handler.assert_called_once()

View File

@@ -1,11 +1,20 @@
import pytest
from unittest import mock
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog
from .client_mocks import mocked_client
from .test_scan_control import available_scans_message
@pytest.fixture
def monaco_widget(qtbot):
widget = MonacoWidget()
def monaco_widget(qtbot, mocked_client):
widget = MonacoWidget(client=mocked_client)
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@@ -37,3 +46,75 @@ def test_monaco_widget_readonly(monaco_widget: MonacoWidget, qtbot):
monaco_widget.set_text("Attempting to change text")
qtbot.waitUntil(lambda: monaco_widget.get_text() == "Attempting to change text", timeout=1000)
assert monaco_widget.get_text() == "Attempting to change text"
def test_monaco_widget_show_scan_control_dialog(monaco_widget: MonacoWidget, qtbot):
"""
Test that the MonacoWidget can show the scan control dialog.
"""
with mock.patch.object(monaco_widget, "_run_dialog_and_insert_code") as mock_run_dialog:
monaco_widget._show_scan_control_dialog()
mock_run_dialog.assert_called_once()
def test_monaco_widget_get_scan_control_code(monaco_widget: MonacoWidget, qtbot, mocked_client):
"""
Test that the MonacoWidget can get scan control code from the dialog.
"""
mocked_client.connector.set(MessageEndpoints.available_scans(), available_scans_message)
scan_control_dialog = ScanControlDialog(client=mocked_client)
qtbot.addWidget(scan_control_dialog)
qtbot.waitExposed(scan_control_dialog)
qtbot.wait(300)
scan_control = scan_control_dialog.scan_control
scan_name = "grid_scan"
kwargs = {"exp_time": 0.2, "settling_time": 0.1, "relative": False, "burst_at_each_point": 2}
args_row1 = {"device": "samx", "start": -10, "stop": 10, "steps": 20}
args_row2 = {"device": "samy", "start": -5, "stop": 5, "steps": 10}
mock_slot = mock.MagicMock()
scan_control.scan_args.connect(mock_slot)
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
# Ensure there are two rows in the arg_box
current_rows = scan_control.arg_box.count_arg_rows()
required_rows = 2
while current_rows < required_rows:
scan_control.arg_box.add_widget_bundle()
current_rows += 1
# Set kwargs in the UI
for kwarg_box in scan_control.kwarg_boxes:
for widget in kwarg_box.widgets:
if widget.arg_name in kwargs:
WidgetIO.set_value(widget, kwargs[widget.arg_name])
# Set args in the UI for both rows
arg_widgets = scan_control.arg_box.widgets # This is a flat list of widgets
num_columns = len(scan_control.arg_box.inputs)
num_rows = int(len(arg_widgets) / num_columns)
assert num_rows == required_rows # We expect 2 rows for grid_scan
# Set values for first row
for i in range(num_columns):
widget = arg_widgets[i]
arg_name = widget.arg_name
if arg_name in args_row1:
WidgetIO.set_value(widget, args_row1[arg_name])
# Set values for second row
for i in range(num_columns):
widget = arg_widgets[num_columns + i] # Next row
arg_name = widget.arg_name
if arg_name in args_row2:
WidgetIO.set_value(widget, args_row2[arg_name])
scan_control_dialog.accept()
out = scan_control_dialog.get_scan_code()
expected_code = "scans.grid_scan(dev.samx, -10.0, 10.0, 20, dev.samy, -5.0, 5.0, 10, exp_time=0.2, settling_time=0.1, burst_at_each_point=2, relative=False, optim_trajectory=None)"
assert out == expected_code