"""
Simple Code Editor Service for PyQt GUI.
Provides modular text editing with QScintilla (default) or external program launch.
No threading complications - keeps it simple and direct.
"""
import logging
import tempfile
import os
import subprocess
from pathlib import Path
from typing import Optional, Callable
from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QTextEdit, QPushButton, QHBoxLayout,
QMessageBox, QMenuBar, QFileDialog, QSplitter)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QAction, QKeySequence
logger = logging.getLogger(__name__)
# Try to import QScintilla, fall back to QTextEdit if not available
try:
from PyQt6.Qsci import QsciScintilla, QsciLexerPython
QSCINTILLA_AVAILABLE = True
logger.info("QScintilla successfully imported")
except ImportError as e:
logger.warning(f"QScintilla not available: {e}")
logger.info("Install with: pip install PyQt6-QScintilla")
QSCINTILLA_AVAILABLE = False
[docs]
class SimpleCodeEditorService:
"""
Simple, modular code editor service.
Uses QScintilla for professional Python editing (default) or external programs.
Falls back to QTextEdit if QScintilla is not available.
No threading - keeps it simple and reliable.
"""
[docs]
def __init__(self, parent_widget):
"""
Initialize the code editor service.
Args:
parent_widget: Parent widget for dialogs
"""
self.parent = parent_widget
[docs]
def edit_code(self, initial_content: str, title: str = "Edit Code",
callback: Optional[Callable[[str], None]] = None,
use_external: bool = False,
code_type: str = None,
code_data: dict = None) -> None:
"""
Edit code using either Qt native editor or external program.
Args:
initial_content: Initial code content
title: Editor window title
callback: Callback function called with edited content
use_external: If True, use external editor; if False, use Qt native
code_type: Type of code being edited ('orchestrator', 'pipeline', 'function', None)
code_data: Data needed to regenerate code (for clean mode toggle)
"""
if use_external:
self._edit_with_external_program(initial_content, callback)
else:
self._edit_with_qt_native(initial_content, title, callback, code_type, code_data)
def _edit_with_qt_native(self, initial_content: str, title: str,
callback: Optional[Callable[[str], None]],
code_type: str = None,
code_data: dict = None,
error_line: int = None) -> None:
"""Edit code using Qt native text editor dialog (QScintilla preferred)."""
try:
if QSCINTILLA_AVAILABLE:
logger.debug("Using QScintilla editor for code editing")
dialog = QScintillaCodeEditorDialog(self.parent, initial_content, title,
callback=callback,
code_type=code_type, code_data=code_data,
initial_line=error_line)
else:
logger.debug("QScintilla not available, using QTextEdit fallback")
dialog = CodeEditorDialog(self.parent, initial_content, title)
# Show dialog as non-blocking floating window (like other OpenHCS windows)
# Use .show() instead of .exec() to allow interaction with other windows
dialog.show()
dialog.raise_()
dialog.activateWindow()
except Exception as e:
logger.error(f"Qt native editor failed: {e}")
self._show_error(f"Editor failed: {str(e)}")
def _edit_with_external_program(self, initial_content: str,
callback: Optional[Callable[[str], None]]) -> None:
"""Edit code using external program (vim, nano, vscode, etc.)."""
try:
# Create temporary file
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(initial_content)
temp_path = Path(f.name)
# Get editor from environment or use default
editor = os.environ.get('EDITOR', 'nano')
# Launch editor and wait for completion
result = subprocess.run([editor, str(temp_path)],
capture_output=False,
text=True)
if result.returncode == 0:
# Read edited content
with open(temp_path, 'r') as f:
edited_content = f.read()
if callback:
callback(edited_content)
else:
self._show_error(f"Editor exited with code {result.returncode}")
except FileNotFoundError:
self._show_error(f"Editor '{editor}' not found. Set EDITOR environment variable or install nano/vim.")
except Exception as e:
logger.error(f"External editor failed: {e}")
self._show_error(f"External editor failed: {str(e)}")
finally:
# Clean up temporary file
try:
if temp_path.exists():
temp_path.unlink()
except:
pass
def _show_error(self, message: str) -> None:
"""Show error message to user."""
QMessageBox.critical(self.parent, "Editor Error", message)
[docs]
class QScintillaCodeEditorDialog(QDialog):
"""
Professional code editor dialog using QScintilla.
Provides Python syntax highlighting, code folding, line numbers, and more.
Integrates with ColorScheme for consistent theming.
Supports clean mode toggle for orchestrator/pipeline/function code.
"""
[docs]
def __init__(self, parent, initial_content: str, title: str,
callback: Optional[Callable[[str], None]] = None,
code_type: str = None, code_data: dict = None, initial_line: int = None):
"""
Initialize code editor dialog.
Args:
parent: Parent widget
initial_content: Initial code content
title: Window title
callback: Callback function called with edited content on successful save
code_type: Type of code being edited ('orchestrator', 'pipeline', 'function', None)
code_data: Data needed to regenerate code (for clean mode toggle)
initial_line: Line number to position cursor at (1-based, None for start)
"""
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(False) # Non-modal - allow other windows to be interactable
self.resize(900, 700)
# Store callback and code generation context
self.callback = callback
self.code_type = code_type
self.code_data = code_data or {}
self.clean_mode = self.code_data.get('clean_mode', True) # Default to clean mode
self.initial_line = initial_line
self.llm_panel: Optional['LLMChatPanel'] = None
self.llm_panel_visible = False
# Get color scheme from parent
from pyqt_reactive.theming import ColorScheme
self.color_scheme = ColorScheme()
# Setup UI
self._setup_ui(initial_content)
# Apply theming
self._apply_theme()
# Move cursor to error line if specified
if self.initial_line is not None:
self._goto_line(self.initial_line)
# Focus on editor
self.editor.setFocus()
def _setup_ui(self, initial_content: str):
"""Setup the UI components."""
# Main layout
main_layout = QVBoxLayout(self)
# Menu bar (compact)
self._setup_menu_bar()
self.menu_bar.setMaximumHeight(25) # Limit menu bar height
main_layout.addWidget(self.menu_bar, 0) # 0 stretch factor
# QScintilla editor
self.editor = QsciScintilla()
self.editor.setText(initial_content)
# Set Python lexer for syntax highlighting
self.lexer = QsciLexerPython()
self.editor.setLexer(self.lexer)
# Configure editor features
self._configure_editor()
# Create splitter for editor and LLM panel
self.splitter = QSplitter(Qt.Orientation.Horizontal)
self.splitter.setChildrenCollapsible(False)
# Add editor to splitter
self.splitter.addWidget(self.editor)
# Create LLM panel (initially hidden)
from pyqt_reactive.widgets.llm_chat_panel import LLMChatPanel
self.llm_panel = LLMChatPanel(
parent=self,
color_scheme=self.color_scheme,
code_type=self.code_type
)
self.llm_panel.code_generated.connect(self._on_llm_code_generated)
self.llm_panel.setVisible(False)
self.splitter.addWidget(self.llm_panel)
# Set initial splitter sizes (editor takes all space when LLM panel hidden)
self.splitter.setSizes([700, 300])
main_layout.addWidget(self.splitter)
# Buttons
button_layout = QHBoxLayout()
# LLM Assist button (toggle)
self.llm_assist_btn = QPushButton("LLM Assist")
self.llm_assist_btn.setCheckable(True)
self.llm_assist_btn.setChecked(False)
self.llm_assist_btn.clicked.connect(self._toggle_llm_panel)
button_layout.addWidget(self.llm_assist_btn)
self.save_btn = QPushButton("Save")
# Support Shift+Click for 'Save without close'
self.save_btn.clicked.connect(self._on_save_clicked)
button_layout.addWidget(self.save_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_btn)
button_layout.addStretch()
main_layout.addLayout(button_layout)
def _setup_menu_bar(self):
"""Setup menu bar with File, Edit, View menus."""
self.menu_bar = QMenuBar(self)
# File menu
file_menu = self.menu_bar.addMenu("&File")
# New action
new_action = QAction("&New", self)
new_action.setShortcut(QKeySequence.StandardKey.New)
new_action.triggered.connect(self._new_file)
file_menu.addAction(new_action)
# Open action
open_action = QAction("&Open...", self)
open_action.setShortcut(QKeySequence.StandardKey.Open)
open_action.triggered.connect(self._open_file)
file_menu.addAction(open_action)
file_menu.addSeparator()
# Save action
save_action = QAction("&Save", self)
save_action.setShortcut(QKeySequence.StandardKey.Save)
save_action.triggered.connect(self.accept)
file_menu.addAction(save_action)
# Save As action
save_as_action = QAction("Save &As...", self)
save_as_action.setShortcut(QKeySequence.StandardKey.SaveAs)
save_as_action.triggered.connect(self._save_as)
file_menu.addAction(save_as_action)
file_menu.addSeparator()
# Close action
close_action = QAction("&Close", self)
close_action.setShortcut(QKeySequence.StandardKey.Close)
close_action.triggered.connect(self.reject)
file_menu.addAction(close_action)
# Edit menu
edit_menu = self.menu_bar.addMenu("&Edit")
# Undo action
undo_action = QAction("&Undo", self)
undo_action.setShortcut(QKeySequence.StandardKey.Undo)
undo_action.triggered.connect(lambda: self.editor.undo())
edit_menu.addAction(undo_action)
# Redo action
redo_action = QAction("&Redo", self)
redo_action.setShortcut(QKeySequence.StandardKey.Redo)
redo_action.triggered.connect(lambda: self.editor.redo())
edit_menu.addAction(redo_action)
edit_menu.addSeparator()
# Cut, Copy, Paste
cut_action = QAction("Cu&t", self)
cut_action.setShortcut(QKeySequence.StandardKey.Cut)
cut_action.triggered.connect(lambda: self.editor.cut())
edit_menu.addAction(cut_action)
copy_action = QAction("&Copy", self)
copy_action.setShortcut(QKeySequence.StandardKey.Copy)
copy_action.triggered.connect(lambda: self.editor.copy())
edit_menu.addAction(copy_action)
paste_action = QAction("&Paste", self)
paste_action.setShortcut(QKeySequence.StandardKey.Paste)
paste_action.triggered.connect(lambda: self.editor.paste())
edit_menu.addAction(paste_action)
edit_menu.addSeparator()
# Select All
select_all_action = QAction("Select &All", self)
select_all_action.setShortcut(QKeySequence.StandardKey.SelectAll)
select_all_action.triggered.connect(lambda: self.editor.selectAll())
edit_menu.addAction(select_all_action)
# View menu
view_menu = self.menu_bar.addMenu("&View")
# Toggle line numbers
toggle_line_numbers = QAction("Toggle &Line Numbers", self)
toggle_line_numbers.setCheckable(True)
toggle_line_numbers.setChecked(True)
toggle_line_numbers.triggered.connect(self._toggle_line_numbers)
view_menu.addAction(toggle_line_numbers)
# Toggle code folding
toggle_folding = QAction("Toggle Code &Folding", self)
toggle_folding.setCheckable(True)
toggle_folding.setChecked(True)
toggle_folding.triggered.connect(self._toggle_code_folding)
view_menu.addAction(toggle_folding)
# Add separator before clean mode toggle
view_menu.addSeparator()
# Toggle clean mode - always available
toggle_clean_mode = QAction("Toggle &Clean Mode", self)
toggle_clean_mode.setCheckable(True)
toggle_clean_mode.setChecked(self.clean_mode)
toggle_clean_mode.triggered.connect(self._toggle_clean_mode)
view_menu.addAction(toggle_clean_mode)
def _configure_editor(self):
"""Configure QScintilla editor with professional features."""
# Line numbers
self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
self.editor.setMarginWidth(0, "0000")
self.editor.setMarginLineNumbers(0, True)
self.editor.setMarginsBackgroundColor(Qt.GlobalColor.lightGray)
# Current line highlighting
self.editor.setCaretLineVisible(True)
self.editor.setCaretLineBackgroundColor(Qt.GlobalColor.lightGray)
# Set font
font = QFont("Consolas", 10)
if not font.exactMatch():
font = QFont("Courier New", 10)
self.editor.setFont(font)
# Indentation
self.editor.setIndentationsUseTabs(False)
self.editor.setIndentationWidth(4)
self.editor.setTabWidth(4)
self.editor.setAutoIndent(True)
# Code folding
self.editor.setFolding(QsciScintilla.FoldStyle.BoxedTreeFoldStyle)
# Brace matching
self.editor.setBraceMatching(QsciScintilla.BraceMatch.SloppyBraceMatch)
# Selection
self.editor.setSelectionBackgroundColor(Qt.GlobalColor.blue)
# Enable UTF-8
self.editor.setUtf8(True)
# Autocomplete disabled - causes Qt event loop crashes with widget deletion
# self._configure_autocomplete()
def _configure_autocomplete(self):
"""Configure autocomplete for Python code."""
logger.info("🔧 Configuring Jedi-powered autocomplete...")
# Use custom autocomplete source (we'll populate it with Jedi)
self.editor.setAutoCompletionSource(QsciScintilla.AutoCompletionSource.AcsAPIs)
logger.info(" ✓ Autocomplete source set to AcsAPIs")
# Show autocomplete after typing 2 characters
self.editor.setAutoCompletionThreshold(2)
logger.info(" ✓ Autocomplete threshold: 2 characters")
# Case-insensitive matching
self.editor.setAutoCompletionCaseSensitivity(False)
# Replace word when selecting from autocomplete
self.editor.setAutoCompletionReplaceWord(True)
# Show single item automatically
self.editor.setAutoCompletionUseSingle(QsciScintilla.AutoCompletionUseSingle.AcusNever)
# Note: setAutoCompletionMaxVisibleItems() doesn't exist in QScintilla
# The list size is automatically managed
# Setup Jedi-based API
self._setup_jedi_api()
# Install event filter to catch key presses for autocomplete triggering
self.editor.installEventFilter(self)
logger.info(" ✓ Event filter installed for '.' trigger")
[docs]
def eventFilter(self, obj, event):
"""Filter events to trigger Jedi autocomplete on '.' """
try:
from PyQt6.QtCore import QEvent
if obj == self.editor and event.type() == QEvent.Type.KeyPress:
key_event = event
# Trigger autocomplete when '.' is typed
if key_event.text() == '.':
logger.info("🔍 Detected '.' keypress - triggering Jedi autocomplete")
# Let the '.' be inserted first
from PyQt6.QtCore import QTimer
# Use a lambda to check if dialog still exists before calling autocomplete
QTimer.singleShot(10, lambda: self._show_jedi_completions() if hasattr(self, 'editor') else None)
return super().eventFilter(obj, event)
except Exception as e:
logger.warning(f"Autocomplete event filter error (ignoring): {e}")
return super().eventFilter(obj, event)
def _setup_jedi_api(self):
"""Setup initial API with basic Python keywords for fallback."""
try:
from PyQt6.Qsci import QsciAPIs
# Create API object for Python lexer
self.api = QsciAPIs(self.lexer)
logger.info(" ✓ Created QsciAPIs object")
# Add basic Python keywords as fallback
python_keywords = [
'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await',
'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except',
'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is',
'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return',
'try', 'while', 'with', 'yield', 'print', 'len', 'range', 'str',
'int', 'float', 'list', 'dict', 'tuple', 'set'
]
for keyword in python_keywords:
self.api.add(keyword)
# Prepare the API
self.api.prepare()
logger.info(f" ✓ Added {len(python_keywords)} Python keywords to API")
except Exception as e:
logger.error(f"❌ Failed to setup Jedi API autocomplete: {e}")
def _show_jedi_completions(self):
"""Show Jedi-powered autocomplete suggestions."""
logger.info("🧠 _show_jedi_completions called")
try:
# Check if editor still exists and is valid
if not hasattr(self, 'editor') or self.editor is None:
logger.warning("Editor widget no longer exists, skipping autocomplete")
return
# Check if editor widget is still valid (not deleted)
try:
# Try to access a property - will raise RuntimeError if C++ object deleted
_ = self.editor.isVisible()
except RuntimeError:
logger.warning("Editor widget has been deleted, skipping autocomplete")
return
# Wrap entire autocomplete logic to catch widget deletion errors
import jedi
# Get current code and cursor position
code = self.editor.text()
line, col = self.editor.getCursorPosition()
# Get the current line text to see what we're completing
current_line = self.editor.text(line)
logger.info(f" 📍 Cursor position: line={line}, col={col}")
logger.info(f" 📝 Current line: '{current_line}'")
logger.info(f" 📝 Code length: {len(code)} chars")
# Check if we're typing an import statement or module access
# If the line starts with 'import' or 'from', add it if not already there
current_line_stripped = current_line.strip()
if current_line_stripped and not current_line_stripped.startswith(('import ', 'from ')):
# User is typing module.attribute without import - add implicit import for Jedi
# Extract the module path before the cursor
before_cursor = current_line[:col].strip()
if '.' in before_cursor:
# Get the base module (everything before last dot)
parts = before_cursor.rsplit('.', 1)
if parts:
module_path = parts[0]
# Add import statement for Jedi
code = f"import {module_path}\n" + code
# Adjust line number since we added a line
jedi_line = line + 2 # +1 for 1-based, +1 for added import
logger.info(f" 💡 Added implicit import for Jedi: 'import {module_path}'")
else:
jedi_line = line + 1
else:
jedi_line = line + 1
else:
jedi_line = line + 1
# jedi_line already set above based on whether we added import
jedi_col = col
# Create Jedi script with current code
# Use project parameter to tell Jedi where to find project modules
import os
# Use configured project roots for Jedi (defaults to cwd)
from pyqt_reactive.protocols import get_form_config
config = get_form_config()
project_paths = [Path(p) for p in getattr(config, "jedi_project_paths", []) if p]
if not project_paths:
project_paths = [Path.cwd()]
project = jedi.Project(
path=str(project_paths[0]),
sys_path=[str(p) for p in project_paths],
)
script = jedi.Script(code, path='<editor>', project=project)
logger.info(f" ✓ Created Jedi script with project root: {project_root}")
# Get completions at cursor position
completions = script.complete(jedi_line, jedi_col)
logger.info(f" 🔍 Jedi returned {len(completions)} completions")
# If no completions, try to get more info about what Jedi sees
if len(completions) == 0:
logger.info(" ⚠️ No completions - Jedi may not be able to resolve the module")
logger.info(f" 💡 Project root: {project_root}")
if completions:
# Log first few completions for debugging
sample = [c.name for c in completions[:5]]
logger.info(f" 📋 Sample completions: {sample}")
# Check if editor is still valid before showing autocomplete
try:
_ = self.editor.isVisible()
except RuntimeError:
logger.warning("Editor deleted before showing completions")
return
# Check if autocomplete is already active
try:
if self.editor.isListActive():
logger.info(" ⚠️ Autocomplete list already active, canceling first")
self.editor.cancelList()
except RuntimeError:
logger.warning("Editor deleted while checking list state")
return
# Build completion list
# Format for each item: "name?type" where ?type is optional
completion_items = []
for c in completions:
if c.type:
completion_items.append(f"{c.name}?{c.type}")
else:
completion_items.append(c.name)
logger.info(f" 📝 Built {len(completion_items)} completion items")
# Use showUserList instead of autoCompleteFromAll to avoid QScintilla's filtering
# showUserList(id, list) - id=1 for user completions, list is an iterable of strings
try:
self.editor.showUserList(1, completion_items)
logger.info(f" ✅ Called showUserList() with {len(completions)} completions")
# Check if it's showing
if self.editor.isListActive():
logger.info(" ✅ Autocomplete list is now active!")
else:
logger.info(" ❌ Autocomplete list is NOT active")
except RuntimeError:
logger.warning("Editor deleted while showing autocomplete list")
return
else:
logger.info(" ⚠️ No Jedi completions - trying standard autocomplete")
# No Jedi completions, try standard autocomplete
try:
self.editor.autoCompleteFromAll()
except RuntimeError:
logger.warning("Editor deleted while showing standard autocomplete")
return
except RuntimeError as e:
# Widget deletion errors - just log as warning and ignore
logger.warning(f"Autocomplete widget error (ignoring): {e}")
except Exception as e:
# Other autocomplete errors - log as warning and try fallback
logger.warning(f"Jedi autocomplete failed (ignoring): {e}")
# Fall back to standard autocomplete
try:
self.editor.autoCompleteFromAll()
except Exception as fallback_error:
logger.warning(f"Standard autocomplete also failed (ignoring): {fallback_error}")
def _apply_theme(self):
"""Apply ColorScheme theming to QScintilla editor."""
cs = self.color_scheme
# Apply dialog styling
self.setStyleSheet(f"""
QDialog {{
background-color: {cs.to_hex(cs.window_bg)};
color: {cs.to_hex(cs.text_primary)};
}}
QPushButton {{
background-color: {cs.to_hex(cs.button_normal_bg)};
color: {cs.to_hex(cs.button_text)};
border: 1px solid {cs.to_hex(cs.border_light)};
border-radius: 3px;
padding: 5px;
min-width: 80px;
}}
QPushButton:hover {{
background-color: {cs.to_hex(cs.button_hover_bg)};
}}
QPushButton:pressed {{
background-color: {cs.to_hex(cs.button_pressed_bg)};
}}
QMenuBar {{
background-color: {cs.to_hex(cs.panel_bg)};
color: {cs.to_hex(cs.text_primary)};
border-bottom: 1px solid {cs.to_hex(cs.border_color)};
}}
QMenuBar::item {{
background-color: transparent;
padding: 4px 8px;
}}
QMenuBar::item:selected {{
background-color: {cs.to_hex(cs.button_hover_bg)};
}}
QMenu {{
background-color: {cs.to_hex(cs.panel_bg)};
color: {cs.to_hex(cs.text_primary)};
border: 1px solid {cs.to_hex(cs.border_color)};
}}
QMenu::item {{
padding: 4px 20px;
}}
QMenu::item:selected {{
background-color: {cs.to_hex(cs.button_hover_bg)};
}}
""")
# Apply QScintilla-specific theming
self._apply_qscintilla_theme()
def _apply_qscintilla_theme(self):
"""Apply color scheme to QScintilla editor and lexer."""
cs = self.color_scheme
# Set editor background and text colors
self.editor.setColor(cs.to_qcolor(cs.text_primary))
self.editor.setPaper(cs.to_qcolor(cs.panel_bg))
# Set margin colors
self.editor.setMarginsBackgroundColor(cs.to_qcolor(cs.frame_bg))
self.editor.setMarginsForegroundColor(cs.to_qcolor(cs.text_secondary))
# Set caret line color
self.editor.setCaretLineBackgroundColor(cs.to_qcolor(cs.selection_bg))
# Set selection colors
self.editor.setSelectionBackgroundColor(cs.to_qcolor(cs.selection_bg))
self.editor.setSelectionForegroundColor(cs.to_qcolor(cs.selection_text))
# Configure Python lexer colors
if self.lexer:
# Keywords (def, class, if, etc.)
self.lexer.setColor(cs.to_qcolor(cs.python_keyword_color), QsciLexerPython.Keyword)
# Strings
self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.SingleQuotedString)
self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.DoubleQuotedString)
self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleSingleQuotedString)
self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleDoubleQuotedString)
# F-strings
self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.SingleQuotedFString)
self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.DoubleQuotedFString)
self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleSingleQuotedFString)
self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleDoubleQuotedFString)
# Comments
self.lexer.setColor(cs.to_qcolor(cs.python_comment_color), QsciLexerPython.Comment)
self.lexer.setColor(cs.to_qcolor(cs.python_comment_color), QsciLexerPython.CommentBlock)
# Numbers
self.lexer.setColor(cs.to_qcolor(cs.python_number_color), QsciLexerPython.Number)
# Functions and classes
self.lexer.setColor(cs.to_qcolor(cs.python_function_color), QsciLexerPython.FunctionMethodName)
self.lexer.setColor(cs.to_qcolor(cs.python_class_color), QsciLexerPython.ClassName)
# Operators
self.lexer.setColor(cs.to_qcolor(cs.python_operator_color), QsciLexerPython.Operator)
# Identifiers
self.lexer.setColor(cs.to_qcolor(cs.python_name_color), QsciLexerPython.Identifier)
self.lexer.setColor(cs.to_qcolor(cs.python_name_color), QsciLexerPython.HighlightedIdentifier)
# Decorators
self.lexer.setColor(cs.to_qcolor(cs.python_function_color), QsciLexerPython.Decorator)
# Set default background for lexer
self.lexer.setPaper(cs.to_qcolor(cs.panel_bg))
# Menu action handlers
def _new_file(self):
"""Clear editor content."""
self.editor.clear()
def _open_file(self):
"""Open file dialog and load content."""
from pyqt_reactive.core.path_cache import PathCacheKey, get_cached_dialog_path, cache_dialog_path
# Get cached initial directory
initial_dir = str(get_cached_dialog_path(PathCacheKey.CODE_EDITOR, fallback=Path.home()))
file_path, _ = QFileDialog.getOpenFileName(
self, "Open Python File", initial_dir, "Python Files (*.py);;All Files (*)"
)
if file_path:
try:
selected_path = Path(file_path)
# Cache the parent directory for future dialogs
cache_dialog_path(PathCacheKey.CODE_EDITOR, selected_path.parent)
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
self.editor.setText(content)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to open file: {str(e)}")
def _save_as(self):
"""Save content to file."""
from pyqt_reactive.core.path_cache import PathCacheKey, get_cached_dialog_path, cache_dialog_path
# Get cached initial directory
initial_dir = str(get_cached_dialog_path(PathCacheKey.CODE_EDITOR, fallback=Path.home()))
file_path, _ = QFileDialog.getSaveFileName(
self, "Save Python File", initial_dir, "Python Files (*.py);;All Files (*)"
)
if file_path:
try:
selected_path = Path(file_path)
# Ensure file always ends with .py extension
if not selected_path.suffix.lower() == '.py':
selected_path = selected_path.with_suffix('.py')
file_path = str(selected_path)
# Cache the parent directory for future dialogs
cache_dialog_path(PathCacheKey.CODE_EDITOR, selected_path.parent)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(self.editor.text())
QMessageBox.information(self, "Success", f"File saved to {file_path}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save file: {str(e)}")
def _toggle_line_numbers(self, checked):
"""Toggle line number display."""
if checked:
self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
self.editor.setMarginWidth(0, "0000")
self.editor.setMarginLineNumbers(0, True)
else:
self.editor.setMarginWidth(0, 0)
def _toggle_code_folding(self, checked):
"""Toggle code folding."""
if checked:
self.editor.setFolding(QsciScintilla.FoldStyle.BoxedTreeFoldStyle)
else:
self.editor.setFolding(QsciScintilla.FoldStyle.NoFoldStyle)
def _toggle_clean_mode(self, checked):
"""Toggle between clean mode (minimal) and explicit mode (full)."""
try:
# Parse current code to extract data
current_code = self.editor.text()
namespace = {}
exec(current_code, namespace)
# Toggle clean mode
self.clean_mode = checked
self.code_data['clean_mode'] = self.clean_mode
# Auto-detect code type from namespace variables
from pyqt_reactive.protocols import get_codegen_provider
provider = get_codegen_provider()
if provider is None:
from PyQt6.QtWidgets import QMessageBox
QMessageBox.warning(self, "Clean Mode Toggle",
"No codegen provider registered. Call register_codegen_provider(...) in the host app.")
return
# Check what variables exist in the namespace to determine code type
if 'plate_paths' in namespace or 'pipeline_data' in namespace:
# Orchestrator code
plate_paths = namespace.get('plate_paths', [])
pipeline_data = namespace.get('pipeline_data', {})
global_config = namespace.get('global_config')
per_plate_configs = namespace.get('per_plate_configs')
pipeline_config = namespace.get('pipeline_config')
new_code = provider.generate_complete_orchestrator_code(
plate_paths=plate_paths,
pipeline_data=pipeline_data,
global_config=global_config,
per_plate_configs=per_plate_configs,
pipeline_config=pipeline_config,
clean_mode=self.clean_mode
)
elif 'pipeline_steps' in namespace:
# Pipeline steps code
pipeline_steps = namespace.get('pipeline_steps', [])
new_code = provider.generate_complete_pipeline_steps_code(
pipeline_steps=pipeline_steps,
clean_mode=self.clean_mode
)
elif 'pattern' in namespace:
# Function pattern code (uses 'pattern' variable name)
pattern = namespace.get('pattern')
new_code = provider.generate_complete_function_pattern_code(
func_obj=pattern,
clean_mode=self.clean_mode
)
elif 'step' in namespace:
step_obj = namespace.get('step')
new_code = provider.generate_step_code(
step_obj,
clean_mode=self.clean_mode
)
elif 'config' in namespace:
# Config code - auto-detect config class from the object
config = namespace.get('config')
config_class = type(config)
new_code = provider.generate_config_code(
config_obj=config,
clean_mode=self.clean_mode,
config_class=config_class,
)
else:
# Unsupported code type
from PyQt6.QtWidgets import QMessageBox
QMessageBox.warning(self, "Clean Mode Toggle",
"Could not detect code type. Expected one of: plate_paths, pipeline_steps, step, pattern, or config variable.")
return
# Update editor with new code
self.editor.setText(new_code)
except Exception as e:
import traceback
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to toggle clean mode: {e}\n{traceback.format_exc()}")
from PyQt6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Clean Mode Toggle Error",
f"Failed to toggle clean mode: {str(e)}")
[docs]
def get_content(self) -> str:
"""Get the edited content."""
return self.editor.text()
def _on_save_clicked(self) -> None:
"""Handle save button click with Shift+Click detection."""
from PyQt6.QtWidgets import QApplication
modifiers = QApplication.keyboardModifiers()
is_shift = modifiers & Qt.KeyboardModifier.ShiftModifier
self._handle_save(close_window=not is_shift)
def _handle_save(self, *, close_window=True) -> None:
"""
Handle save button click - validate code before closing.
Only closes dialog if callback succeeds and close_window=True, otherwise shows error and keeps dialog open.
Args:
close_window: If True, close dialog after successful save. If False (Shift+Click), keep open.
"""
logger.info(f"Save button clicked (close_window={close_window})")
if self.callback is None:
# No callback, just close if requested
if close_window:
logger.info("No callback, closing dialog")
self.accept()
return
edited_content = self.get_content()
try:
# Try to execute the callback
logger.info("Executing callback...")
self.callback(edited_content)
# Success - close the dialog only if close_window=True
if close_window:
logger.info("Callback succeeded, closing dialog")
self.accept()
else:
logger.info("Callback succeeded, keeping dialog open (Shift+Click)")
except Exception as e:
# Error - extract line number and show error
logger.error(f"Callback failed with error: {e}")
error_line = self._extract_error_line(e)
logger.info(f"Extracted error line: {error_line}")
# Show error message
error_msg = str(e)
if error_line:
error_msg = f"Line {error_line}: {error_msg}"
from PyQt6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error Parsing Code", error_msg)
# Move cursor to error line
if error_line:
logger.info(f"Moving cursor to line {error_line}")
self._goto_line(error_line)
logger.info("Keeping dialog open for user to fix error")
# Keep dialog open - user can fix the error or click Cancel
# Do NOT call self.accept() or self.reject() here
def _extract_error_line(self, exception: Exception) -> Optional[int]:
"""Extract line number from exception if available."""
# For SyntaxError, use lineno attribute
if isinstance(exception, SyntaxError) and hasattr(exception, 'lineno'):
return exception.lineno
# For other exceptions, try to extract from traceback
import traceback
import sys
tb = sys.exc_info()[2]
if tb:
# Find the frame that executed the user's code (marked as '<string>')
for frame_summary in traceback.extract_tb(tb):
if '<string>' in frame_summary.filename:
return frame_summary.lineno
return None
def _goto_line(self, line_number: int) -> None:
"""
Move cursor to specified line and highlight it.
Args:
line_number: Line number to go to (1-based)
"""
if line_number is None or line_number < 1:
return
# Convert to 0-based line number for QScintilla
line_index = line_number - 1
# Move cursor to the line
self.editor.setCursorPosition(line_index, 0)
# Ensure the line is visible
self.editor.ensureLineVisible(line_index)
# Select the entire line to highlight it
line_length = self.editor.lineLength(line_index)
self.editor.setSelection(line_index, 0, line_index, line_length)
def _toggle_llm_panel(self, checked: bool):
"""Toggle LLM assist panel visibility."""
self.llm_panel_visible = checked
self.llm_panel.setVisible(checked)
if checked:
# Show panel - adjust splitter sizes
self.splitter.setSizes([500, 400])
self.llm_panel.user_input.setFocus()
else:
# Hide panel - editor takes all space
self.splitter.setSizes([900, 0])
self.editor.setFocus()
def _on_llm_code_generated(self, generated_code: str):
"""Handle code generated by LLM panel."""
# Insert generated code at cursor position
cursor_line, cursor_index = self.editor.getCursorPosition()
self.editor.insert(generated_code)
# Optionally: auto-hide panel after insertion
# self.llm_assist_btn.setChecked(False)
# self._toggle_llm_panel(False)
[docs]
class CodeEditorDialog(QDialog):
"""
Fallback Qt native code editor dialog using QTextEdit.
Used when QScintilla is not available.
"""
[docs]
def __init__(self, parent, initial_content: str, title: str):
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(True)
self.resize(800, 600)
# Setup UI
layout = QVBoxLayout(self)
# Text editor
self.text_edit = QTextEdit()
self.text_edit.setPlainText(initial_content)
# Use monospace font for code
font = QFont("Consolas", 10)
if not font.exactMatch():
font = QFont("Courier New", 10)
self.text_edit.setFont(font)
layout.addWidget(self.text_edit)
# Buttons
button_layout = QHBoxLayout()
save_btn = QPushButton("Save")
save_btn.clicked.connect(self.accept)
button_layout.addWidget(save_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(cancel_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
# Focus on text editor
self.text_edit.setFocus()
[docs]
def get_content(self) -> str:
"""Get the edited content."""
return self.text_edit.toPlainText()