1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-05 00:12:49 +01:00

feat: add developer view

This commit is contained in:
2025-08-18 16:47:16 +02:00
committed by Klaus Wakonig
parent b43abfafcd
commit 7a43111de0
10 changed files with 1717 additions and 23 deletions

View File

@@ -0,0 +1,433 @@
import re
from typing import List
import markdown
import PySide6QtAds as QtAds
from bec_lib.endpoints import MessageEndpoints
from bec_lib.script_executor import upload_script
from bec_qthemes import material_icon
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import Qt, QTimer
from qtpy.QtGui import QKeySequence, QShortcut
from qtpy.QtWidgets import QSplitter, QTextEdit, QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.editors.monaco.monaco_tab import MonacoDock
from bec_widgets.widgets.editors.web_console.web_console import WebConsole
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
def 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.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)
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 DeveloperView(BECWidget, QWidget):
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
self.toolbar = ModularToolBar(self)
self.init_developer_toolbar()
self._root_layout.addWidget(self.toolbar)
self.dock_manager = CDockManager(self)
self.dock_manager.setStyleSheet("")
self._root_layout.addWidget(self.dock_manager)
# Initialize the widgets
self.explorer = IDEExplorer(self)
self.console = WebConsole(self)
self.terminal = WebConsole(self, startup_cmd="")
self.monaco = MonacoDock(self)
self.monaco.save_enabled.connect(self._on_save_enabled_update)
self.plotting_ads = AdvancedDockArea(self, mode="plot", default_add_direction="bottom")
self.signature_help = QTextEdit(self)
self.signature_help.setAcceptRichText(True)
self.signature_help.setReadOnly(True)
self.signature_help.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
opt = self.signature_help.document().defaultTextOption()
opt.setWrapMode(opt.WrapMode.WrapAnywhere)
self.signature_help.document().setDefaultTextOption(opt)
self.monaco.signature_help.connect(
lambda text: self.signature_help.setHtml(markdown_to_html(text))
)
# Create the dock widgets
self.explorer_dock = QtAds.CDockWidget("Explorer", self)
self.explorer_dock.setWidget(self.explorer)
self.console_dock = QtAds.CDockWidget("Console", self)
self.console_dock.setWidget(self.console)
self.monaco_dock = QtAds.CDockWidget("Monaco Editor", self)
self.monaco_dock.setWidget(self.monaco)
self.terminal_dock = QtAds.CDockWidget("Terminal", self)
self.terminal_dock.setWidget(self.terminal)
# Monaco will be central widget
self.dock_manager.setCentralWidget(self.monaco_dock)
# Add the dock widgets to the dock manager
area_bottom = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.BottomDockWidgetArea, self.console_dock
)
self.dock_manager.addDockWidgetTabToArea(self.terminal_dock, area_bottom)
area_left = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.LeftDockWidgetArea, self.explorer_dock
)
area_left.titleBar().setVisible(False)
for dock in self.dock_manager.dockWidgets():
# dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)#TODO implement according to MonacoDock or AdvancedDockArea
# dock.setFeature(CDockWidget.CustomCloseHandling, True) #TODO same
dock.setFeature(CDockWidget.DockWidgetClosable, False)
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetMovable, False)
self.plotting_ads_dock = QtAds.CDockWidget("Plotting Area", self)
self.plotting_ads_dock.setWidget(self.plotting_ads)
self.signature_dock = QtAds.CDockWidget("Signature Help", self)
self.signature_dock.setWidget(self.signature_help)
area_right = self.dock_manager.addDockWidget(
QtAds.DockWidgetArea.RightDockWidgetArea, self.plotting_ads_dock
)
self.dock_manager.addDockWidgetTabToArea(self.signature_dock, area_right)
# Apply stretch after the layout is done
self.set_default_view([2, 5, 3], [7, 3])
# Connect editor signals
self.explorer.file_open_requested.connect(self._open_new_file)
self.toolbar.show_bundles(["save", "execution", "settings"])
def init_developer_toolbar(self):
"""Initialize the developer toolbar with necessary actions and widgets."""
save_button = MaterialIconAction(
icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self
)
save_button.action.triggered.connect(self.on_save)
self.toolbar.components.add_safe("save", save_button)
save_as_button = MaterialIconAction(
icon_name="save_as", tooltip="Save As", label_text="Save As", parent=self
)
self.toolbar.components.add_safe("save_as", save_as_button)
save_bundle = ToolbarBundle("save", self.toolbar.components)
save_bundle.add_action("save")
save_bundle.add_action("save_as")
self.toolbar.add_bundle(save_bundle)
run_action = MaterialIconAction(
icon_name="play_arrow",
tooltip="Run current file",
label_text="Run",
filled=True,
parent=self,
)
run_action.action.triggered.connect(self.on_execute)
self.toolbar.components.add_safe("run", run_action)
stop_action = MaterialIconAction(
icon_name="stop",
tooltip="Stop current execution",
label_text="Stop",
filled=True,
parent=self,
)
stop_action.action.triggered.connect(self.on_stop)
self.toolbar.components.add_safe("stop", stop_action)
execution_bundle = ToolbarBundle("execution", self.toolbar.components)
execution_bundle.add_action("run")
execution_bundle.add_action("stop")
self.toolbar.add_bundle(execution_bundle)
vim_action = MaterialIconAction(
icon_name="vim",
tooltip="Toggle Vim Mode",
label_text="Vim",
filled=True,
parent=self,
checkable=True,
)
self.toolbar.components.add_safe("vim", vim_action)
vim_action.action.triggered.connect(self.on_vim_triggered)
settings_bundle = ToolbarBundle("settings", self.toolbar.components)
settings_bundle.add_action("vim")
self.toolbar.add_bundle(settings_bundle)
save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self)
save_shortcut.activated.connect(self.on_save)
save_as_shortcut = QShortcut(QKeySequence("Ctrl+Shift+S"), self)
save_as_shortcut.activated.connect(self.on_save_as)
####### 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.Horizontal:
splitters_h.append(splitter)
elif splitter.orientation() == Qt.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)
def _open_new_file(self, file_name: str, scope: str):
self.monaco.open_file(file_name)
# Set read-only mode for shared files
if "shared" in scope:
self.monaco.set_file_readonly(file_name, True)
# Add appropriate icon based on file type
if "script" in scope:
# Use script icon for script files
icon = material_icon("script", size=(24, 24))
self.monaco.set_file_icon(file_name, icon)
elif "macro" in scope:
# Use function icon for macro files
icon = material_icon("function", size=(24, 24))
self.monaco.set_file_icon(file_name, icon)
@SafeSlot()
def on_save(self):
self.monaco.save_file()
@SafeSlot()
def on_save_as(self):
self.monaco.save_file(force_save_as=True)
@SafeSlot()
def on_vim_triggered(self):
self.monaco.set_vim_mode(self.toolbar.components.get_action("vim").action.isChecked())
@SafeSlot(bool)
def _on_save_enabled_update(self, enabled: bool):
self.toolbar.components.get_action("save").action.setEnabled(enabled)
self.toolbar.components.get_action("save_as").action.setEnabled(enabled)
@SafeSlot()
def on_execute(self):
self.script_editor_tab = self.monaco.last_focused_editor
if not self.script_editor_tab:
return
self.current_script_id = upload_script(
self.client.connector, self.script_editor_tab.widget().get_text()
)
self.console.write(f'bec._run_script("{self.current_script_id}")')
print(f"Uploaded script with ID: {self.current_script_id}")
@SafeSlot()
def on_stop(self):
print("Stopping execution...")
@property
def current_script_id(self):
return self._current_script_id
@current_script_id.setter
def current_script_id(self, value):
if not isinstance(value, str):
raise ValueError("Script ID must be a string.")
self._current_script_id = value
self._update_subscription()
def _update_subscription(self):
if self.current_script_id:
self.bec_dispatcher.connect_slot(
self.on_script_execution_info,
MessageEndpoints.script_execution_info(self.current_script_id),
)
else:
self.bec_dispatcher.disconnect_slot(
self.on_script_execution_info,
MessageEndpoints.script_execution_info(self.current_script_id),
)
@SafeSlot(dict, dict)
def on_script_execution_info(self, content: dict, metadata: dict):
print(f"Script execution info: {content}")
current_lines = content.get("current_lines")
if not current_lines:
self.script_editor_tab.widget().clear_highlighted_lines()
return
line_number = current_lines[0]
self.script_editor_tab.widget().clear_highlighted_lines()
self.script_editor_tab.widget().set_highlighted_lines(line_number, line_number)
if __name__ == "__main__":
import sys
from bec_qthemes import apply_theme
from qtpy.QtWidgets import QApplication
from bec_widgets.applications.main_app import BECMainApp
app = QApplication(sys.argv)
apply_theme("dark")
_app = BECMainApp()
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

@@ -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,408 @@
import ast
import os
from pathlib import Path
from typing import Any
from bec_lib.logger import bec_logger
from qtpy.QtCore import QModelIndex, QRect, Qt, Signal
from qtpy.QtGui import QPainter, QStandardItem, QStandardItemModel
from qtpy.QtWidgets import QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
from bec_widgets.utils.toolbars.actions import MaterialIconAction
logger = bec_logger.logger
class MacroItemDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover for macro functions"""
def __init__(self, parent=None):
super().__init__(parent)
self.hovered_index = QModelIndex()
self.macro_actions: list[Any] = []
self.button_rects: list[QRect] = []
self.current_macro_info = {}
def add_macro_action(self, action: Any) -> None:
"""Add an action for macro functions"""
self.macro_actions.append(action)
def clear_actions(self) -> None:
"""Remove all actions"""
self.macro_actions.clear()
def paint(self, painter, option, index):
"""Paint the item with action buttons on hover"""
# Paint the default item
super().paint(painter, option, index)
# Early return if not hovering over this item
if index != self.hovered_index:
return
# Only show actions for macro functions (not directories)
item = index.model().itemFromIndex(index)
if not item or not item.data(Qt.ItemDataRole.UserRole):
return
macro_info = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(macro_info, dict) or "function_name" not in macro_info:
return
self.current_macro_info = macro_info
if self.macro_actions:
self._draw_action_buttons(painter, option, self.macro_actions)
def _draw_action_buttons(self, painter, option, actions: list[Any]):
"""Draw action buttons on the right side"""
button_size = 18
margin = 4
spacing = 2
# Calculate total width needed for all buttons
total_width = len(actions) * button_size + (len(actions) - 1) * spacing
# Clear previous button rects and create new ones
self.button_rects.clear()
# Calculate starting position (right side of the item)
start_x = option.rect.right() - total_width - margin
current_x = start_x
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Get theme colors for better integration
palette = get_theme_palette()
button_bg = palette.button().color()
button_bg.setAlpha(150) # Semi-transparent
for action in actions:
if not action.isVisible():
continue
# Calculate button position
button_rect = QRect(
current_x,
option.rect.top() + (option.rect.height() - button_size) // 2,
button_size,
button_size,
)
self.button_rects.append(button_rect)
# Draw button background
painter.setBrush(button_bg)
painter.setPen(palette.mid().color())
painter.drawRoundedRect(button_rect, 3, 3)
# Draw action icon
icon = action.icon()
if not icon.isNull():
icon_rect = button_rect.adjusted(2, 2, -2, -2)
icon.paint(painter, icon_rect)
# Move to next button position
current_x += button_size + spacing
painter.restore()
def editorEvent(self, event, model, option, index):
"""Handle mouse events for action buttons"""
# Early return if not a left click
if not (
event.type() == event.Type.MouseButtonPress
and event.button() == Qt.MouseButton.LeftButton
):
return super().editorEvent(event, model, option, index)
# Check which button was clicked
visible_actions = [action for action in self.macro_actions if action.isVisible()]
for i, button_rect in enumerate(self.button_rects):
if button_rect.contains(event.pos()) and i < len(visible_actions):
# Trigger the action
visible_actions[i].trigger()
return True
return super().editorEvent(event, model, option, index)
def set_hovered_index(self, index):
"""Set the currently hovered index"""
self.hovered_index = index
class MacroTreeWidget(QWidget):
"""A tree widget that displays macro functions from Python files"""
macro_selected = Signal(str, str) # Function name, file path
macro_open_requested = Signal(str, str) # Function name, file path
def __init__(self, parent=None):
super().__init__(parent)
# Create layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create tree view
self.tree = QTreeView()
self.tree.setHeaderHidden(True)
self.tree.setRootIsDecorated(True)
# Disable editing to prevent renaming on double-click
self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers)
# Enable mouse tracking for hover effects
self.tree.setMouseTracking(True)
# Create model for macro functions
self.model = QStandardItemModel()
self.tree.setModel(self.model)
# Create and set custom delegate
self.delegate = MacroItemDelegate(self.tree)
self.tree.setItemDelegate(self.delegate)
# Add default open button for macros
action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self)
action.action.triggered.connect(self._on_macro_open_requested)
self.delegate.add_macro_action(action.action)
# Apply BEC styling
self._apply_styling()
# Macro specific properties
self.directory = None
# Connect signals
self.tree.clicked.connect(self._on_item_clicked)
self.tree.doubleClicked.connect(self._on_item_double_clicked)
# Install event filter for hover tracking
self.tree.viewport().installEventFilter(self)
# Add to layout
layout.addWidget(self.tree)
def _apply_styling(self):
"""Apply styling to the tree widget"""
# Get theme colors for subtle tree lines
palette = get_theme_palette()
subtle_line_color = palette.mid().color()
subtle_line_color.setAlpha(80)
# Standard editable styling
opacity_modifier = ""
cursor_style = ""
# pylint: disable=f-string-without-interpolation
tree_style = f"""
QTreeView {{
border: none;
outline: 0;
show-decoration-selected: 0;
{opacity_modifier}
{cursor_style}
}}
QTreeView::branch {{
border-image: none;
background: transparent;
}}
QTreeView::item {{
border: none;
padding: 0px;
margin: 0px;
}}
QTreeView::item:hover {{
background: palette(midlight);
border: none;
padding: 0px;
margin: 0px;
text-decoration: none;
}}
QTreeView::item:selected {{
background: palette(highlight);
color: palette(highlighted-text);
}}
QTreeView::item:selected:hover {{
background: palette(highlight);
}}
"""
self.tree.setStyleSheet(tree_style)
def eventFilter(self, obj, event):
"""Handle mouse move events for hover tracking"""
# Early return if not the tree viewport
if obj != self.tree.viewport():
return super().eventFilter(obj, event)
if event.type() == event.Type.MouseMove:
index = self.tree.indexAt(event.pos())
if index.isValid():
self.delegate.set_hovered_index(index)
else:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
if event.type() == event.Type.Leave:
self.delegate.set_hovered_index(QModelIndex())
self.tree.viewport().update()
return super().eventFilter(obj, event)
return super().eventFilter(obj, event)
def set_directory(self, directory):
"""Set the macros directory and scan for macro functions"""
self.directory = directory
# Early return if directory doesn't exist
if not directory or not os.path.exists(directory):
return
self._scan_macro_functions()
def _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:
# Skip files starting with underscore
if py_file.name.startswith("_"):
continue
try:
functions = self._extract_functions_from_file(py_file)
if functions:
# 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)
self.model.appendRow(file_item)
except Exception as e:
logger.warning(f"Failed to parse {py_file}: {e}")
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 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

@@ -3,7 +3,7 @@ from pathlib import Path
from bec_lib.logger import bec_logger
from qtpy.QtCore import QModelIndex, QRect, QRegularExpression, QSortFilterProxyModel, Qt, Signal
from qtpy.QtGui import QAction, QPainter
from qtpy.QtGui import QPainter
from qtpy.QtWidgets import QFileSystemModel, QStyledItemDelegate, QTreeView, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_palette
@@ -15,19 +15,20 @@ logger = bec_logger.logger
class FileItemDelegate(QStyledItemDelegate):
"""Custom delegate to show action buttons on hover"""
def __init__(self, parent=None):
super().__init__(parent)
def __init__(self, tree_widget):
super().__init__(tree_widget)
self.setObjectName("file_item_delegate")
self.hovered_index = QModelIndex()
self.file_actions: list[QAction] = []
self.dir_actions: list[QAction] = []
self.button_rects: list[QRect] = []
self.file_actions = []
self.dir_actions = []
self.button_rects = []
self.current_file_path = ""
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)
@@ -67,7 +68,7 @@ class FileItemDelegate(QStyledItemDelegate):
if actions:
self._draw_action_buttons(painter, option, actions)
def _draw_action_buttons(self, painter, option, actions: list[QAction]):
def _draw_action_buttons(self, painter, option, actions):
"""Draw action buttons on the right side"""
button_size = 18
margin = 4
@@ -229,12 +230,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;
@@ -357,11 +364,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,393 @@
from __future__ import annotations
import os
import pathlib
from typing import Any, cast
import PySide6QtAds as QtAds
from bec_lib.logger import bec_logger
from PySide6QtAds import CDockWidget
from qtpy.QtCore import QEvent, QTimer, Signal
from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.widgets.editors.monaco.monaco_widget import MonacoWidget
logger = bec_logger.logger
class MonacoDock(BECWidget, QWidget):
"""
MonacoDock is a dock widget that contains Monaco editor instances.
It is used to manage multiple Monaco editors in a dockable interface.
"""
focused_editor = Signal(object) # Emitted when the focused editor changes
save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled
signature_help = Signal(str) # Emitted when signature help is requested
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
self.dock_manager = QtAds.CDockManager(self)
self.dock_manager.setStyleSheet("")
self.dock_manager.focusedDockWidgetChanged.connect(self._on_focus_event)
self._root_layout.addWidget(self.dock_manager)
self.dock_manager.installEventFilter(self)
self._last_focused_editor: MonacoWidget | None = None
self.focused_editor.connect(self._on_last_focused_editor_changed)
self.add_editor()
self._open_files = {}
def _create_editor(self):
widget = MonacoWidget(self)
widget.save_enabled.connect(self.save_enabled.emit)
widget.editor.signature_help_triggered.connect(self._on_signature_change)
count = len(self.dock_manager.dockWidgets())
dock = CDockWidget(f"Untitled_{count + 1}")
dock.setWidget(widget)
# Connect to modification status changes to update tab titles
widget.save_enabled.connect(
lambda modified: self._update_tab_title_for_modification(dock, modified)
)
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
dock.setFeature(CDockWidget.CustomCloseHandling, True)
dock.setFeature(CDockWidget.DockWidgetClosable, True)
dock.setFeature(CDockWidget.DockWidgetFloatable, False)
dock.setFeature(CDockWidget.DockWidgetMovable, True)
dock.closeRequested.connect(lambda: self._on_editor_close_requested(dock, widget))
return dock
@property
def last_focused_editor(self) -> CDockWidget | None:
"""
Get the last focused editor.
"""
return self._last_focused_editor
@last_focused_editor.setter
def last_focused_editor(self, editor: CDockWidget | None):
self._last_focused_editor = editor
self.focused_editor.emit(editor)
def _on_last_focused_editor_changed(self, editor: CDockWidget | None):
if editor is None:
self.save_enabled.emit(False)
return
widget = cast(MonacoWidget, editor.widget())
if widget.modified:
logger.info(f"Editor '{widget.current_file}' has unsaved changes: {widget.get_text()}")
self.save_enabled.emit(widget.modified)
def _update_tab_title_for_modification(self, dock: CDockWidget, modified: bool):
"""Update the tab title to show modification status with a dot indicator."""
current_title = dock.windowTitle()
# Remove existing modification indicator (dot and space)
if current_title.startswith(""):
base_title = current_title[2:] # Remove "• "
else:
base_title = current_title
# Add or remove the modification indicator
if modified:
new_title = f"{base_title}"
else:
new_title = base_title
dock.setWindowTitle(new_title)
def _on_signature_change(self, signature: dict):
signatures = signature.get("signatures", [])
if not signatures:
self.signature_help.emit("")
return
active_sig = signatures[signature.get("activeSignature", 0)]
active_param = signature.get("activeParameter", 0) # TODO: Add highlight for active_param
# Get signature label and documentation
label = active_sig.get("label", "")
doc_obj = active_sig.get("documentation", {})
documentation = doc_obj.get("value", "") if isinstance(doc_obj, dict) else str(doc_obj)
# Format the markdown output
markdown = f"```python\n{label}\n```\n\n{documentation}"
self.signature_help.emit(markdown)
def _on_focus_event(self, old_widget, new_widget) -> None:
# Track focus events for the dock widget
widget = new_widget.widget()
if isinstance(widget, MonacoWidget):
self.last_focused_editor = new_widget
def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget):
# Cast widget to MonacoWidget since we know that's what it is
monaco_widget = cast(MonacoWidget, widget)
# Check if we have unsaved changes
if monaco_widget.modified:
# Prompt the user to save changes
response = QMessageBox.question(
self,
"Unsaved Changes",
"You have unsaved changes. Do you want to save them?",
QMessageBox.StandardButton.Yes
| QMessageBox.StandardButton.No
| QMessageBox.StandardButton.Cancel,
)
if response == QMessageBox.StandardButton.Yes:
self.save_file(monaco_widget)
elif response == QMessageBox.StandardButton.Cancel:
return
# Count all editor docks managed by this dock manager
total = len(self.dock_manager.dockWidgets())
if total <= 1:
# Do not remove the last dock; just wipe its editor content
# Temporarily disable read-only mode if the editor is read-only
# so we can clear the content for reuse
monaco_widget.set_readonly(False)
monaco_widget.set_text("")
dock.setWindowTitle("Untitled")
dock.setTabToolTip("Untitled")
return
# Otherwise, proceed to close and delete the dock
monaco_widget.close()
dock.closeDockWidget()
dock.deleteDockWidget()
if self.last_focused_editor is dock:
self.last_focused_editor = None
# After topology changes, make sure single-tab areas get a plus button
QTimer.singleShot(0, self._scan_and_fix_areas)
def _ensure_area_plus(self, area):
if area is None:
return
# Only add once per area
if getattr(area, "_monaco_plus_btn", None) is not None:
return
# If the area has exactly one tab, inject a + button next to the tab bar
try:
tabbar = area.titleBar().tabBar()
count = tabbar.count() if hasattr(tabbar, "count") else 1
except Exception:
count = 1
if count >= 1:
plus_btn = QToolButton(area)
plus_btn.setText("+")
plus_btn.setToolTip("New Monaco Editor")
plus_btn.setAutoRaise(True)
tb = area.titleBar()
idx = tb.indexOf(tb.tabBar())
tb.insertWidget(idx + 1, plus_btn)
plus_btn.clicked.connect(lambda: self.add_editor(area))
# pylint: disable=protected-access
area._monaco_plus_btn = plus_btn
def _scan_and_fix_areas(self):
# Find all dock areas under this manager and ensure each single-tab area has a plus button
areas = self.dock_manager.findChildren(QtAds.CDockAreaWidget)
for a in areas:
self._ensure_area_plus(a)
def eventFilter(self, obj, event):
# Track dock manager events
if obj is self.dock_manager and event.type() in (
QEvent.Type.ChildAdded,
QEvent.Type.ChildRemoved,
QEvent.Type.LayoutRequest,
):
QTimer.singleShot(0, self._scan_and_fix_areas)
return super().eventFilter(obj, event)
def add_editor(
self, area: Any | None = None, title: str | None = None, tooltip: str | None = None
): # Any as qt ads does not return a proper type
"""
Adds a new Monaco editor dock widget to the dock manager.
"""
new_dock = self._create_editor()
if title is not None:
new_dock.setWindowTitle(title)
if tooltip is not None:
new_dock.setTabToolTip(tooltip)
if area is None:
area_obj = self.dock_manager.addDockWidgetTab(QtAds.TopDockWidgetArea, new_dock)
self._ensure_area_plus(area_obj)
else:
# If an area is provided, add the dock to that area
self.dock_manager.addDockWidgetTabToArea(new_dock, area)
self._ensure_area_plus(area)
QTimer.singleShot(0, self._scan_and_fix_areas)
return new_dock
def open_file(self, file_name: str):
"""
Open a file in the specified area. If the file is already open, activate it.
"""
open_files = self._get_open_files()
if file_name in open_files:
dock = self._get_editor_dock(file_name)
if dock is not None:
dock.setAsCurrentTab()
return
file = os.path.basename(file_name)
# If the current editor is empty, we reuse it
# For now, the dock manager is only for the editor docks. We can therefore safely assume
# that all docks are editor docks.
dock_area = self.dock_manager.dockArea(0)
editor_dock = dock_area.currentDockWidget()
editor_widget = editor_dock.widget() if editor_dock else None
if editor_widget:
editor_widget = cast(MonacoWidget, editor_dock.widget())
if editor_widget.current_file is None and editor_widget.get_text() == "":
editor_dock.setWindowTitle(file)
editor_dock.setTabToolTip(file_name)
editor_widget.open_file(file_name)
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)
def save_file(
self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True
) -> None:
"""
Save the currently focused file.
Args:
widget (MonacoWidget | None): The widget to save. If None, the last focused editor will be used.
force_save_as (bool): If True, the "Save As" dialog will be shown even if the file is already saved.
"""
if widget is None:
widget = self.last_focused_editor.widget() if self.last_focused_editor else None
if not widget:
return
if 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())
# pylint: disable=protected-access
widget._original_content = widget.get_text()
widget.save_enabled.emit(False)
return
# Save as option
save_file = QFileDialog.getSaveFileName(self, "Save File As", "", "All files (*)")
if save_file:
# check if we have suffix specified
file = pathlib.Path(save_file[0])
if file.suffix == "":
file = file.with_suffix(".py")
if format_on_save and file.suffix == ".py":
widget.format()
text = widget.get_text()
with open(file, "w", encoding="utf-8") as f:
f.write(text)
widget._original_content = text
# Update the current_file before emitting save_enabled to ensure proper tracking
widget._current_file = str(file)
widget.save_enabled.emit(False)
# Find the dock widget containing this monaco widget and update title
for dock in self.dock_manager.dockWidgets():
if dock.widget() == widget:
dock.setWindowTitle(file.name)
dock.setTabToolTip(str(file))
break
print(f"Save file called, last focused editor: {self.last_focused_editor}")
def set_vim_mode(self, enabled: bool):
"""
Set Vim mode for all editor widgets.
Args:
enabled (bool): Whether to enable or disable Vim mode.
"""
for widget in self.dock_manager.dockWidgets():
editor_widget = cast(MonacoWidget, widget.widget())
editor_widget.set_vim_mode_enabled(enabled)
def _get_open_files(self) -> list[str]:
open_files = []
for widget in self.dock_manager.dockWidgets():
editor_widget = cast(MonacoWidget, widget.widget())
if editor_widget.current_file is not None:
open_files.append(editor_widget.current_file)
return open_files
def _get_editor_dock(self, file_name: str) -> CDockWidget | None:
for widget in self.dock_manager.dockWidgets():
editor_widget = cast(MonacoWidget, widget.widget())
if editor_widget.current_file == file_name:
return widget
return None
def set_file_readonly(self, file_name: str, read_only: bool = True) -> bool:
"""
Set a specific file's editor to read-only mode.
Args:
file_name (str): The file path to set read-only
read_only (bool): Whether to set read-only mode (default: True)
Returns:
bool: True if the file was found and read-only was set, False otherwise
"""
editor_dock = self._get_editor_dock(file_name)
if editor_dock:
editor_widget = cast(MonacoWidget, editor_dock.widget())
editor_widget.set_readonly(read_only)
return True
return False
def set_file_icon(self, file_name: str, icon) -> bool:
"""
Set an icon for a specific file's tab.
Args:
file_name (str): The file path to set icon for
icon: The QIcon to set on the tab
Returns:
bool: True if the file was found and icon was set, False otherwise
"""
editor_dock = self._get_editor_dock(file_name)
if editor_dock:
editor_dock.setIcon(icon)
return True
return False
if __name__ == "__main__":
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
_dock = MonacoDock()
_dock.show()
sys.exit(app.exec())

View File

@@ -1,11 +1,19 @@
import os
import traceback
from typing import Literal
import black
import isort
import qtmonaco
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QApplication, 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
logger = bec_logger.logger
class MonacoWidget(BECWidget, QWidget):
@@ -14,6 +22,7 @@ class MonacoWidget(BECWidget, QWidget):
"""
text_changed = Signal(str)
save_enabled = Signal(bool)
PLUGIN = True
ICON_NAME = "code"
USER_ACCESS = [
@@ -21,6 +30,7 @@ class MonacoWidget(BECWidget, QWidget):
"get_text",
"insert_text",
"delete_line",
"open_file",
"set_language",
"get_language",
"set_theme",
@@ -47,7 +57,19 @@ 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 = ""
@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 +83,17 @@ 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) -> None:
"""
Set the text in the Monaco editor.
Args:
text (str): The text to set in the editor.
file_name (str): Set the file name
"""
self.editor.set_text(text)
self._current_file = file_name
self._original_content = text
self.editor.set_text(text, uri=file_name)
def get_text(self) -> str:
"""
@@ -76,6 +101,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 +147,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)
@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 +290,36 @@ class MonacoWidget(BECWidget, QWidget):
"""
return self.editor.get_lsp_header()
def _setup_context_menu(self):
"""Setup custom context menu actions for the Monaco editor."""
# Add the "Insert Scan" action to the context menu
self.editor.add_action("insert_scan", "Insert Scan", "python")
# Add the "Format Code" action to the context menu
self.editor.add_action("format_code", "Format Code", "python")
def _handle_context_menu_action(self, action_id: str):
"""Handle context menu action triggers."""
if action_id == "insert_scan":
self._show_scan_control_dialog()
elif action_id == "format_code":
self._format_code()
def _show_scan_control_dialog(self):
"""Show the scan control dialog and insert the generated scan code."""
# Import here to avoid circular imports
from bec_widgets.widgets.editors.monaco.scan_control_dialog import ScanControlDialog
dialog = ScanControlDialog(self, client=self.client)
if dialog.exec_() == QDialog.DialogCode.Accepted:
scan_code = dialog.get_scan_code()
if scan_code:
# Insert the scan code at the current cursor position
self.insert_text(scan_code)
def _format_code(self):
"""Format the current code in the editor."""
self.format()
if __name__ == "__main__": # pragma: no cover
qapp = QApplication([])
@@ -234,7 +341,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,18 @@
import datetime
import importlib
import importlib.metadata
import os
import re
from bec_qthemes import material_icon
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QInputDialog, QMessageBox, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty
from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection
from bec_widgets.widgets.containers.explorer.explorer import Explorer
from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget
from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget
@@ -17,16 +22,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 +43,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 +60,18 @@ class IDEExplorer(BECWidget, QWidget):
match section_name.lower():
case "scripts":
self.add_script_section()
case "macros":
self.add_macro_section()
case _:
pass
def add_script_section(self):
section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0)
section.expanded = False
script_explorer = Explorer(parent=self)
script_widget = ScriptTreeWidget(parent=self)
script_widget.file_open_requested.connect(self._emit_file_open_scripts_local)
script_widget.file_selected.connect(self._emit_file_preview_scripts_local)
local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
local_scripts_section.header_add_button.clicked.connect(self._add_local_script)
local_scripts_section.set_widget(script_widget)
@@ -77,15 +94,85 @@ class IDEExplorer(BECWidget, QWidget):
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)
shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared)
shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared)
# macros_section = CollapsibleSection("MACROS", indentation=0)
# macros_section.set_widget(QLabel("Macros will be implemented later"))
# self.main_explorer.add_section(macros_section)
def add_macro_section(self):
section = CollapsibleSection(
parent=self, title="MACROS", indentation=0, show_add_button=True
)
section.header_add_button.setIcon(material_icon("refresh", size=(20, 20)))
section.header_add_button.setToolTip("Reload all macros")
section.header_add_button.clicked.connect(self._reload_macros)
macro_explorer = Explorer(parent=self)
macro_widget = MacroTreeWidget(parent=self)
macro_widget.macro_open_requested.connect(self._emit_file_open_macros_local)
macro_widget.macro_selected.connect(self._emit_file_preview_macros_local)
local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self)
local_macros_section.header_add_button.clicked.connect(self._add_local_macro)
local_macros_section.set_widget(macro_widget)
local_macro_dir = self.client._service_config.model.user_macros.base_path
if not os.path.exists(local_macro_dir):
os.makedirs(local_macro_dir)
macro_widget.set_directory(local_macro_dir)
macro_explorer.add_section(local_macros_section)
section.set_widget(macro_explorer)
self.main_explorer.add_section(section)
plugin_macros_dir = None
plugins = importlib.metadata.entry_points(group="bec")
for plugin in plugins:
if plugin.name == "plugin_bec":
plugin = plugin.load()
plugin_macros_dir = os.path.join(plugin.__path__[0], "macros")
break
if not plugin_macros_dir or not os.path.exists(plugin_macros_dir):
return
shared_macro_section = CollapsibleSection(title="Shared (Read-only)", parent=self)
shared_macro_section.setToolTip("Shared macros (read-only)")
shared_macro_widget = MacroTreeWidget(parent=self)
shared_macro_section.set_widget(shared_macro_widget)
shared_macro_widget.set_directory(plugin_macros_dir)
macro_explorer.add_section(shared_macro_section)
shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared)
shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared)
def _emit_file_open_scripts_local(self, file_name: str):
self.file_open_requested.emit(file_name, "scripts/local")
def _emit_file_preview_scripts_local(self, file_name: str):
self.file_preview_requested.emit(file_name, "scripts/local")
def _emit_file_open_scripts_shared(self, file_name: str):
self.file_open_requested.emit(file_name, "scripts/shared")
def _emit_file_preview_scripts_shared(self, file_name: str):
self.file_preview_requested.emit(file_name, "scripts/shared")
def _emit_file_open_macros_local(self, function_name: str, file_path: str):
self.file_open_requested.emit(file_path, "macros/local")
def _emit_file_preview_macros_local(self, function_name: str, file_path: str):
self.file_preview_requested.emit(file_path, "macros/local")
def _emit_file_open_macros_shared(self, function_name: str, file_path: str):
self.file_open_requested.emit(file_path, "macros/shared")
def _emit_file_preview_macros_shared(self, function_name: str, file_path: str):
self.file_preview_requested.emit(file_path, "macros/shared")
def _add_local_script(self):
"""Show a dialog to enter the name of a new script and create it."""
@@ -136,6 +223,98 @@ class IDEExplorer(BECWidget, QWidget):
# Show error if file creation failed
QMessageBox.critical(self, "Error", f"Failed to create script: {str(e)}")
def _add_local_macro(self):
"""Show a dialog to enter the name of a new macro function and create it."""
target_section = self.main_explorer.get_section("MACROS")
macro_dir_section = target_section.content_widget.get_section("Local")
local_macro_dir = macro_dir_section.content_widget.directory
# Prompt user for function name
function_name, ok = QInputDialog.getText(self, "New Macro", f"Enter macro function name:")
if not ok or not function_name:
return # User cancelled or didn't enter a name
# Sanitize function name
function_name = re.sub(r"[^a-zA-Z0-9_]", "_", function_name)
if not function_name or function_name[0].isdigit():
QMessageBox.warning(
self, "Invalid Name", "Function name must be a valid Python identifier."
)
return
# Create filename based on function name
filename = f"{function_name}.py"
file_path = os.path.join(local_macro_dir, filename)
# Check if file already exists
if os.path.exists(file_path):
response = QMessageBox.question(
self,
"File exists",
f"The file '{filename}' already exists. Do you want to overwrite it?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if response != QMessageBox.StandardButton.Yes:
return # User chose not to overwrite
try:
# Create the file with a macro function template
with open(file_path, "w", encoding="utf-8") as f:
f.write(
f'''"""
{function_name} macro - Created at {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
"""
def {function_name}():
"""
Description of what this macro does.
Add your macro implementation here.
"""
print(f"Executing macro: {function_name}")
# TODO: Add your macro code here
pass
'''
)
# Refresh the macro tree to show the new function
macro_dir_section.content_widget.refresh()
except Exception as e:
# Show error if file creation failed
QMessageBox.critical(self, "Error", f"Failed to create macro: {str(e)}")
def _reload_macros(self):
"""Reload all macros using the BEC client."""
try:
if hasattr(self.client, "macros"):
self.client.macros.load_all_user_macros()
# Refresh the macro tree widgets to show updated functions
target_section = self.main_explorer.get_section("MACROS")
if target_section and hasattr(target_section, "content_widget"):
local_section = target_section.content_widget.get_section("Local")
if local_section and hasattr(local_section, "content_widget"):
local_section.content_widget.refresh()
shared_section = target_section.content_widget.get_section("Shared")
if shared_section and hasattr(shared_section, "content_widget"):
shared_section.content_widget.refresh()
QMessageBox.information(
self, "Reload Macros", "Macros have been reloaded successfully."
)
else:
QMessageBox.warning(self, "Reload Macros", "Macros functionality is not available.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}")
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication

View File

@@ -0,0 +1,22 @@
import pytest
from qtpy.QtWidgets import QTreeView, QWidget
from bec_widgets.utils.colors import apply_theme
class DummyTree(QWidget):
def __init__(self):
super().__init__()
tree = QTreeView(self)
@pytest.fixture
def tree_widget(qtbot):
tree = DummyTree()
qtbot.addWidget(tree)
qtbot.waitExposed(tree)
yield tree
def test_tree_widget_init(tree_widget):
assert isinstance(tree_widget, QWidget)