Coverage for openhcs/pyqt_gui/services/simple_code_editor.py: 0.0%
457 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""
2Simple Code Editor Service for PyQt GUI.
4Provides modular text editing with QScintilla (default) or external program launch.
5No threading complications - keeps it simple and direct.
6"""
8import logging
9import tempfile
10import os
11import subprocess
12from pathlib import Path
13from typing import Optional, Callable
15from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QTextEdit, QPushButton, QHBoxLayout,
16 QMessageBox, QMenuBar, QFileDialog)
17from PyQt6.QtCore import Qt
18from PyQt6.QtGui import QFont, QAction, QKeySequence
20logger = logging.getLogger(__name__)
22# Try to import QScintilla, fall back to QTextEdit if not available
23try:
24 from PyQt6.Qsci import QsciScintilla, QsciLexerPython
25 QSCINTILLA_AVAILABLE = True
26 logger.info("QScintilla successfully imported")
27except ImportError as e:
28 logger.warning(f"QScintilla not available: {e}")
29 logger.info("Install with: pip install PyQt6-QScintilla")
30 QSCINTILLA_AVAILABLE = False
33class SimpleCodeEditorService:
34 """
35 Simple, modular code editor service.
37 Uses QScintilla for professional Python editing (default) or external programs.
38 Falls back to QTextEdit if QScintilla is not available.
39 No threading - keeps it simple and reliable.
40 """
42 def __init__(self, parent_widget):
43 """
44 Initialize the code editor service.
46 Args:
47 parent_widget: Parent widget for dialogs
48 """
49 self.parent = parent_widget
51 def edit_code(self, initial_content: str, title: str = "Edit Code",
52 callback: Optional[Callable[[str], None]] = None,
53 use_external: bool = False,
54 code_type: str = None,
55 code_data: dict = None) -> None:
56 """
57 Edit code using either Qt native editor or external program.
59 Args:
60 initial_content: Initial code content
61 title: Editor window title
62 callback: Callback function called with edited content
63 use_external: If True, use external editor; if False, use Qt native
64 code_type: Type of code being edited ('orchestrator', 'pipeline', 'function', None)
65 code_data: Data needed to regenerate code (for clean mode toggle)
66 """
67 if use_external:
68 self._edit_with_external_program(initial_content, callback)
69 else:
70 self._edit_with_qt_native(initial_content, title, callback, code_type, code_data)
72 def _edit_with_qt_native(self, initial_content: str, title: str,
73 callback: Optional[Callable[[str], None]],
74 code_type: str = None,
75 code_data: dict = None,
76 error_line: int = None) -> None:
77 """Edit code using Qt native text editor dialog (QScintilla preferred)."""
78 try:
79 if QSCINTILLA_AVAILABLE:
80 logger.debug("Using QScintilla editor for code editing")
81 dialog = QScintillaCodeEditorDialog(self.parent, initial_content, title,
82 callback=callback,
83 code_type=code_type, code_data=code_data,
84 initial_line=error_line)
85 else:
86 logger.debug("QScintilla not available, using QTextEdit fallback")
87 dialog = CodeEditorDialog(self.parent, initial_content, title)
89 # Execute dialog - callback is now handled inside the dialog
90 dialog.exec()
92 except Exception as e:
93 logger.error(f"Qt native editor failed: {e}")
94 self._show_error(f"Editor failed: {str(e)}")
96 def _edit_with_external_program(self, initial_content: str,
97 callback: Optional[Callable[[str], None]]) -> None:
98 """Edit code using external program (vim, nano, vscode, etc.)."""
99 try:
100 # Create temporary file
101 with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
102 f.write(initial_content)
103 temp_path = Path(f.name)
105 # Get editor from environment or use default
106 editor = os.environ.get('EDITOR', 'nano')
108 # Launch editor and wait for completion
109 result = subprocess.run([editor, str(temp_path)],
110 capture_output=False,
111 text=True)
113 if result.returncode == 0:
114 # Read edited content
115 with open(temp_path, 'r') as f:
116 edited_content = f.read()
118 if callback:
119 callback(edited_content)
120 else:
121 self._show_error(f"Editor exited with code {result.returncode}")
123 except FileNotFoundError:
124 self._show_error(f"Editor '{editor}' not found. Set EDITOR environment variable or install nano/vim.")
125 except Exception as e:
126 logger.error(f"External editor failed: {e}")
127 self._show_error(f"External editor failed: {str(e)}")
128 finally:
129 # Clean up temporary file
130 try:
131 if temp_path.exists():
132 temp_path.unlink()
133 except:
134 pass
136 def _show_error(self, message: str) -> None:
137 """Show error message to user."""
138 QMessageBox.critical(self.parent, "Editor Error", message)
141class QScintillaCodeEditorDialog(QDialog):
142 """
143 Professional code editor dialog using QScintilla.
145 Provides Python syntax highlighting, code folding, line numbers, and more.
146 Integrates with PyQt6ColorScheme for consistent theming.
147 Supports clean mode toggle for orchestrator/pipeline/function code.
148 """
150 def __init__(self, parent, initial_content: str, title: str,
151 callback: Optional[Callable[[str], None]] = None,
152 code_type: str = None, code_data: dict = None, initial_line: int = None):
153 """
154 Initialize code editor dialog.
156 Args:
157 parent: Parent widget
158 initial_content: Initial code content
159 title: Window title
160 callback: Callback function called with edited content on successful save
161 code_type: Type of code being edited ('orchestrator', 'pipeline', 'function', None)
162 code_data: Data needed to regenerate code (for clean mode toggle)
163 initial_line: Line number to position cursor at (1-based, None for start)
164 """
165 super().__init__(parent)
166 self.setWindowTitle(title)
167 self.setModal(True)
168 self.resize(900, 700)
170 # Store callback and code generation context
171 self.callback = callback
172 self.code_type = code_type
173 self.code_data = code_data or {}
174 self.clean_mode = self.code_data.get('clean_mode', True) # Default to clean mode
175 self.initial_line = initial_line
177 # Get color scheme from parent
178 from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
179 self.color_scheme = PyQt6ColorScheme()
181 # Setup UI
182 self._setup_ui(initial_content)
184 # Apply theming
185 self._apply_theme()
187 # Move cursor to error line if specified
188 if self.initial_line is not None:
189 self._goto_line(self.initial_line)
191 # Focus on editor
192 self.editor.setFocus()
196 def _setup_ui(self, initial_content: str):
197 """Setup the UI components."""
198 # Main layout
199 main_layout = QVBoxLayout(self)
201 # Menu bar
202 self._setup_menu_bar()
203 main_layout.addWidget(self.menu_bar)
205 # QScintilla editor
206 self.editor = QsciScintilla()
207 self.editor.setText(initial_content)
209 # Set Python lexer for syntax highlighting
210 self.lexer = QsciLexerPython()
211 self.editor.setLexer(self.lexer)
213 # Configure editor features
214 self._configure_editor()
216 main_layout.addWidget(self.editor)
218 # Buttons
219 button_layout = QHBoxLayout()
221 self.save_btn = QPushButton("Save")
222 self.save_btn.clicked.connect(self._handle_save)
223 button_layout.addWidget(self.save_btn)
225 self.cancel_btn = QPushButton("Cancel")
226 self.cancel_btn.clicked.connect(self.reject)
227 button_layout.addWidget(self.cancel_btn)
229 button_layout.addStretch()
230 main_layout.addLayout(button_layout)
232 def _setup_menu_bar(self):
233 """Setup menu bar with File, Edit, View menus."""
235 self.menu_bar = QMenuBar(self)
237 # File menu
238 file_menu = self.menu_bar.addMenu("&File")
240 # New action
241 new_action = QAction("&New", self)
242 new_action.setShortcut(QKeySequence.StandardKey.New)
243 new_action.triggered.connect(self._new_file)
244 file_menu.addAction(new_action)
246 # Open action
247 open_action = QAction("&Open...", self)
248 open_action.setShortcut(QKeySequence.StandardKey.Open)
249 open_action.triggered.connect(self._open_file)
250 file_menu.addAction(open_action)
252 file_menu.addSeparator()
254 # Save action
255 save_action = QAction("&Save", self)
256 save_action.setShortcut(QKeySequence.StandardKey.Save)
257 save_action.triggered.connect(self.accept)
258 file_menu.addAction(save_action)
260 # Save As action
261 save_as_action = QAction("Save &As...", self)
262 save_as_action.setShortcut(QKeySequence.StandardKey.SaveAs)
263 save_as_action.triggered.connect(self._save_as)
264 file_menu.addAction(save_as_action)
266 file_menu.addSeparator()
268 # Close action
269 close_action = QAction("&Close", self)
270 close_action.setShortcut(QKeySequence.StandardKey.Close)
271 close_action.triggered.connect(self.reject)
272 file_menu.addAction(close_action)
274 # Edit menu
275 edit_menu = self.menu_bar.addMenu("&Edit")
277 # Undo action
278 undo_action = QAction("&Undo", self)
279 undo_action.setShortcut(QKeySequence.StandardKey.Undo)
280 undo_action.triggered.connect(lambda: self.editor.undo())
281 edit_menu.addAction(undo_action)
283 # Redo action
284 redo_action = QAction("&Redo", self)
285 redo_action.setShortcut(QKeySequence.StandardKey.Redo)
286 redo_action.triggered.connect(lambda: self.editor.redo())
287 edit_menu.addAction(redo_action)
289 edit_menu.addSeparator()
291 # Cut, Copy, Paste
292 cut_action = QAction("Cu&t", self)
293 cut_action.setShortcut(QKeySequence.StandardKey.Cut)
294 cut_action.triggered.connect(lambda: self.editor.cut())
295 edit_menu.addAction(cut_action)
297 copy_action = QAction("&Copy", self)
298 copy_action.setShortcut(QKeySequence.StandardKey.Copy)
299 copy_action.triggered.connect(lambda: self.editor.copy())
300 edit_menu.addAction(copy_action)
302 paste_action = QAction("&Paste", self)
303 paste_action.setShortcut(QKeySequence.StandardKey.Paste)
304 paste_action.triggered.connect(lambda: self.editor.paste())
305 edit_menu.addAction(paste_action)
307 edit_menu.addSeparator()
309 # Select All
310 select_all_action = QAction("Select &All", self)
311 select_all_action.setShortcut(QKeySequence.StandardKey.SelectAll)
312 select_all_action.triggered.connect(lambda: self.editor.selectAll())
313 edit_menu.addAction(select_all_action)
315 # View menu
316 view_menu = self.menu_bar.addMenu("&View")
318 # Toggle line numbers
319 toggle_line_numbers = QAction("Toggle &Line Numbers", self)
320 toggle_line_numbers.setCheckable(True)
321 toggle_line_numbers.setChecked(True)
322 toggle_line_numbers.triggered.connect(self._toggle_line_numbers)
323 view_menu.addAction(toggle_line_numbers)
325 # Toggle code folding
326 toggle_folding = QAction("Toggle Code &Folding", self)
327 toggle_folding.setCheckable(True)
328 toggle_folding.setChecked(True)
329 toggle_folding.triggered.connect(self._toggle_code_folding)
330 view_menu.addAction(toggle_folding)
332 # Add separator before clean mode toggle
333 view_menu.addSeparator()
335 # Toggle clean mode - always available
336 toggle_clean_mode = QAction("Toggle &Clean Mode", self)
337 toggle_clean_mode.setCheckable(True)
338 toggle_clean_mode.setChecked(self.clean_mode)
339 toggle_clean_mode.triggered.connect(self._toggle_clean_mode)
340 view_menu.addAction(toggle_clean_mode)
342 def _configure_editor(self):
343 """Configure QScintilla editor with professional features."""
344 # Line numbers
345 self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
346 self.editor.setMarginWidth(0, "0000")
347 self.editor.setMarginLineNumbers(0, True)
348 self.editor.setMarginsBackgroundColor(Qt.GlobalColor.lightGray)
350 # Current line highlighting
351 self.editor.setCaretLineVisible(True)
352 self.editor.setCaretLineBackgroundColor(Qt.GlobalColor.lightGray)
354 # Set font
355 font = QFont("Consolas", 10)
356 if not font.exactMatch():
357 font = QFont("Courier New", 10)
358 self.editor.setFont(font)
360 # Indentation
361 self.editor.setIndentationsUseTabs(False)
362 self.editor.setIndentationWidth(4)
363 self.editor.setTabWidth(4)
364 self.editor.setAutoIndent(True)
366 # Code folding
367 self.editor.setFolding(QsciScintilla.FoldStyle.BoxedTreeFoldStyle)
369 # Brace matching
370 self.editor.setBraceMatching(QsciScintilla.BraceMatch.SloppyBraceMatch)
372 # Selection
373 self.editor.setSelectionBackgroundColor(Qt.GlobalColor.blue)
375 # Enable UTF-8
376 self.editor.setUtf8(True)
378 # Configure autocomplete
379 self._configure_autocomplete()
381 def _configure_autocomplete(self):
382 """Configure autocomplete for Python code."""
383 logger.info("🔧 Configuring Jedi-powered autocomplete...")
385 # Use custom autocomplete source (we'll populate it with Jedi)
386 self.editor.setAutoCompletionSource(QsciScintilla.AutoCompletionSource.AcsAPIs)
387 logger.info(" ✓ Autocomplete source set to AcsAPIs")
389 # Show autocomplete after typing 2 characters
390 self.editor.setAutoCompletionThreshold(2)
391 logger.info(" ✓ Autocomplete threshold: 2 characters")
393 # Case-insensitive matching
394 self.editor.setAutoCompletionCaseSensitivity(False)
396 # Replace word when selecting from autocomplete
397 self.editor.setAutoCompletionReplaceWord(True)
399 # Show single item automatically
400 self.editor.setAutoCompletionUseSingle(QsciScintilla.AutoCompletionUseSingle.AcusNever)
402 # Note: setAutoCompletionMaxVisibleItems() doesn't exist in QScintilla
403 # The list size is automatically managed
405 # Setup Jedi-based API
406 self._setup_jedi_api()
408 # Install event filter to catch key presses for autocomplete triggering
409 self.editor.installEventFilter(self)
410 logger.info(" ✓ Event filter installed for '.' trigger")
412 def eventFilter(self, obj, event):
413 """Filter events to trigger Jedi autocomplete on '.' """
414 from PyQt6.QtCore import QEvent
416 if obj == self.editor and event.type() == QEvent.Type.KeyPress:
417 key_event = event
418 # Trigger autocomplete when '.' is typed
419 if key_event.text() == '.':
420 logger.info("🔍 Detected '.' keypress - triggering Jedi autocomplete")
421 # Let the '.' be inserted first
422 from PyQt6.QtCore import QTimer
423 QTimer.singleShot(10, self._show_jedi_completions)
425 return super().eventFilter(obj, event)
427 def _setup_jedi_api(self):
428 """Setup initial API with basic Python keywords for fallback."""
429 try:
430 from PyQt6.Qsci import QsciAPIs
432 # Create API object for Python lexer
433 self.api = QsciAPIs(self.lexer)
434 logger.info(" ✓ Created QsciAPIs object")
436 # Add basic Python keywords as fallback
437 python_keywords = [
438 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await',
439 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except',
440 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is',
441 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return',
442 'try', 'while', 'with', 'yield', 'print', 'len', 'range', 'str',
443 'int', 'float', 'list', 'dict', 'tuple', 'set'
444 ]
446 for keyword in python_keywords:
447 self.api.add(keyword)
449 # Prepare the API
450 self.api.prepare()
452 logger.info(f" ✓ Added {len(python_keywords)} Python keywords to API")
454 except Exception as e:
455 logger.error(f"❌ Failed to setup Jedi API autocomplete: {e}")
457 def _show_jedi_completions(self):
458 """Show Jedi-powered autocomplete suggestions."""
459 logger.info("🧠 _show_jedi_completions called")
460 try:
461 import jedi
463 # Get current code and cursor position
464 code = self.editor.text()
465 line, col = self.editor.getCursorPosition()
467 # Get the current line text to see what we're completing
468 current_line = self.editor.text(line)
469 logger.info(f" 📍 Cursor position: line={line}, col={col}")
470 logger.info(f" 📝 Current line: '{current_line}'")
471 logger.info(f" 📝 Code length: {len(code)} chars")
473 # Check if we're typing an import statement or module access
474 # If the line starts with 'import' or 'from', add it if not already there
475 current_line_stripped = current_line.strip()
476 if current_line_stripped and not current_line_stripped.startswith(('import ', 'from ')):
477 # User is typing module.attribute without import - add implicit import for Jedi
478 # Extract the module path before the cursor
479 before_cursor = current_line[:col].strip()
480 if '.' in before_cursor:
481 # Get the base module (everything before last dot)
482 parts = before_cursor.rsplit('.', 1)
483 if parts:
484 module_path = parts[0]
485 # Add import statement for Jedi
486 code = f"import {module_path}\n" + code
487 # Adjust line number since we added a line
488 jedi_line = line + 2 # +1 for 1-based, +1 for added import
489 logger.info(f" 💡 Added implicit import for Jedi: 'import {module_path}'")
490 else:
491 jedi_line = line + 1
492 else:
493 jedi_line = line + 1
494 else:
495 jedi_line = line + 1
497 # jedi_line already set above based on whether we added import
498 jedi_col = col
500 # Create Jedi script with current code
501 # Use project parameter to tell Jedi where to find openhcs modules
502 import os
504 # Get project root (where openhcs package is)
505 project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
507 # Create Jedi project
508 project = jedi.Project(path=project_root)
510 script = jedi.Script(code, path='<editor>', project=project)
511 logger.info(f" ✓ Created Jedi script with project root: {project_root}")
513 # Get completions at cursor position
514 completions = script.complete(jedi_line, jedi_col)
515 logger.info(f" 🔍 Jedi returned {len(completions)} completions")
517 # If no completions, try to get more info about what Jedi sees
518 if len(completions) == 0:
519 logger.info(" ⚠️ No completions - Jedi may not be able to resolve the module")
520 logger.info(f" 💡 Project root: {project_root}")
522 if completions:
523 # Log first few completions for debugging
524 sample = [c.name for c in completions[:5]]
525 logger.info(f" 📋 Sample completions: {sample}")
527 # Check if autocomplete is already active
528 if self.editor.isListActive():
529 logger.info(" ⚠️ Autocomplete list already active, canceling first")
530 self.editor.cancelList()
532 # Build completion list
533 # Format for each item: "name?type" where ?type is optional
534 completion_items = []
535 for c in completions:
536 if c.type:
537 completion_items.append(f"{c.name}?{c.type}")
538 else:
539 completion_items.append(c.name)
541 logger.info(f" 📝 Built {len(completion_items)} completion items")
543 # Use showUserList instead of autoCompleteFromAll to avoid QScintilla's filtering
544 # showUserList(id, list) - id=1 for user completions, list is an iterable of strings
545 self.editor.showUserList(1, completion_items)
546 logger.info(f" ✅ Called showUserList() with {len(completions)} completions")
548 # Check if it's showing
549 if self.editor.isListActive():
550 logger.info(" ✅ Autocomplete list is now active!")
551 else:
552 logger.info(" ❌ Autocomplete list is NOT active")
554 else:
555 logger.info(" ⚠️ No Jedi completions - trying standard autocomplete")
556 # No Jedi completions, try standard autocomplete
557 self.editor.autoCompleteFromAll()
559 except Exception as e:
560 logger.error(f"❌ Jedi autocomplete failed: {e}", exc_info=True)
561 # Fall back to standard autocomplete
562 try:
563 self.editor.autoCompleteFromAll()
564 except:
565 pass
567 def _apply_theme(self):
568 """Apply PyQt6ColorScheme theming to QScintilla editor."""
569 cs = self.color_scheme
571 # Apply dialog styling
572 self.setStyleSheet(f"""
573 QDialog {{
574 background-color: {cs.to_hex(cs.window_bg)};
575 color: {cs.to_hex(cs.text_primary)};
576 }}
577 QPushButton {{
578 background-color: {cs.to_hex(cs.button_normal_bg)};
579 color: {cs.to_hex(cs.button_text)};
580 border: 1px solid {cs.to_hex(cs.border_light)};
581 border-radius: 3px;
582 padding: 5px;
583 min-width: 80px;
584 }}
585 QPushButton:hover {{
586 background-color: {cs.to_hex(cs.button_hover_bg)};
587 }}
588 QPushButton:pressed {{
589 background-color: {cs.to_hex(cs.button_pressed_bg)};
590 }}
591 QMenuBar {{
592 background-color: {cs.to_hex(cs.panel_bg)};
593 color: {cs.to_hex(cs.text_primary)};
594 border-bottom: 1px solid {cs.to_hex(cs.border_color)};
595 }}
596 QMenuBar::item {{
597 background-color: transparent;
598 padding: 4px 8px;
599 }}
600 QMenuBar::item:selected {{
601 background-color: {cs.to_hex(cs.button_hover_bg)};
602 }}
603 QMenu {{
604 background-color: {cs.to_hex(cs.panel_bg)};
605 color: {cs.to_hex(cs.text_primary)};
606 border: 1px solid {cs.to_hex(cs.border_color)};
607 }}
608 QMenu::item {{
609 padding: 4px 20px;
610 }}
611 QMenu::item:selected {{
612 background-color: {cs.to_hex(cs.button_hover_bg)};
613 }}
614 """)
616 # Apply QScintilla-specific theming
617 self._apply_qscintilla_theme()
619 def _apply_qscintilla_theme(self):
620 """Apply color scheme to QScintilla editor and lexer."""
621 cs = self.color_scheme
623 # Set editor background and text colors
624 self.editor.setColor(cs.to_qcolor(cs.text_primary))
625 self.editor.setPaper(cs.to_qcolor(cs.panel_bg))
627 # Set margin colors
628 self.editor.setMarginsBackgroundColor(cs.to_qcolor(cs.frame_bg))
629 self.editor.setMarginsForegroundColor(cs.to_qcolor(cs.text_secondary))
631 # Set caret line color
632 self.editor.setCaretLineBackgroundColor(cs.to_qcolor(cs.selection_bg))
634 # Set selection colors
635 self.editor.setSelectionBackgroundColor(cs.to_qcolor(cs.selection_bg))
636 self.editor.setSelectionForegroundColor(cs.to_qcolor(cs.selection_text))
638 # Configure Python lexer colors
639 if self.lexer:
640 # Keywords (def, class, if, etc.)
641 self.lexer.setColor(cs.to_qcolor(cs.python_keyword_color), QsciLexerPython.Keyword)
643 # Strings
644 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.SingleQuotedString)
645 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.DoubleQuotedString)
646 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleSingleQuotedString)
647 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleDoubleQuotedString)
649 # F-strings
650 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.SingleQuotedFString)
651 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.DoubleQuotedFString)
652 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleSingleQuotedFString)
653 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleDoubleQuotedFString)
655 # Comments
656 self.lexer.setColor(cs.to_qcolor(cs.python_comment_color), QsciLexerPython.Comment)
657 self.lexer.setColor(cs.to_qcolor(cs.python_comment_color), QsciLexerPython.CommentBlock)
659 # Numbers
660 self.lexer.setColor(cs.to_qcolor(cs.python_number_color), QsciLexerPython.Number)
662 # Functions and classes
663 self.lexer.setColor(cs.to_qcolor(cs.python_function_color), QsciLexerPython.FunctionMethodName)
664 self.lexer.setColor(cs.to_qcolor(cs.python_class_color), QsciLexerPython.ClassName)
666 # Operators
667 self.lexer.setColor(cs.to_qcolor(cs.python_operator_color), QsciLexerPython.Operator)
669 # Identifiers
670 self.lexer.setColor(cs.to_qcolor(cs.python_name_color), QsciLexerPython.Identifier)
671 self.lexer.setColor(cs.to_qcolor(cs.python_name_color), QsciLexerPython.HighlightedIdentifier)
673 # Decorators
674 self.lexer.setColor(cs.to_qcolor(cs.python_function_color), QsciLexerPython.Decorator)
676 # Set default background for lexer
677 self.lexer.setPaper(cs.to_qcolor(cs.panel_bg))
679 # Menu action handlers
680 def _new_file(self):
681 """Clear editor content."""
682 self.editor.clear()
684 def _open_file(self):
685 """Open file dialog and load content."""
686 from openhcs.core.path_cache import PathCacheKey, get_cached_dialog_path, cache_dialog_path
688 # Get cached initial directory
689 initial_dir = str(get_cached_dialog_path(PathCacheKey.CODE_EDITOR, fallback=Path.home()))
691 file_path, _ = QFileDialog.getOpenFileName(
692 self, "Open Python File", initial_dir, "Python Files (*.py);;All Files (*)"
693 )
694 if file_path:
695 try:
696 selected_path = Path(file_path)
697 # Cache the parent directory for future dialogs
698 cache_dialog_path(PathCacheKey.CODE_EDITOR, selected_path.parent)
700 with open(file_path, 'r', encoding='utf-8') as f:
701 content = f.read()
702 self.editor.setText(content)
703 except Exception as e:
704 QMessageBox.critical(self, "Error", f"Failed to open file: {str(e)}")
706 def _save_as(self):
707 """Save content to file."""
708 from openhcs.core.path_cache import PathCacheKey, get_cached_dialog_path, cache_dialog_path
710 # Get cached initial directory
711 initial_dir = str(get_cached_dialog_path(PathCacheKey.CODE_EDITOR, fallback=Path.home()))
713 file_path, _ = QFileDialog.getSaveFileName(
714 self, "Save Python File", initial_dir, "Python Files (*.py);;All Files (*)"
715 )
716 if file_path:
717 try:
718 selected_path = Path(file_path)
720 # Ensure file always ends with .py extension
721 if not selected_path.suffix.lower() == '.py':
722 selected_path = selected_path.with_suffix('.py')
723 file_path = str(selected_path)
725 # Cache the parent directory for future dialogs
726 cache_dialog_path(PathCacheKey.CODE_EDITOR, selected_path.parent)
728 with open(file_path, 'w', encoding='utf-8') as f:
729 f.write(self.editor.text())
730 QMessageBox.information(self, "Success", f"File saved to {file_path}")
731 except Exception as e:
732 QMessageBox.critical(self, "Error", f"Failed to save file: {str(e)}")
734 def _toggle_line_numbers(self, checked):
735 """Toggle line number display."""
736 if checked:
737 self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin)
738 self.editor.setMarginWidth(0, "0000")
739 self.editor.setMarginLineNumbers(0, True)
740 else:
741 self.editor.setMarginWidth(0, 0)
743 def _toggle_code_folding(self, checked):
744 """Toggle code folding."""
745 if checked:
746 self.editor.setFolding(QsciScintilla.FoldStyle.BoxedTreeFoldStyle)
747 else:
748 self.editor.setFolding(QsciScintilla.FoldStyle.NoFoldStyle)
750 def _toggle_clean_mode(self, checked):
751 """Toggle between clean mode (minimal) and explicit mode (full)."""
752 try:
753 # Parse current code to extract data
754 current_code = self.editor.text()
755 namespace = {}
756 exec(current_code, namespace)
758 # Toggle clean mode
759 self.clean_mode = checked
760 self.code_data['clean_mode'] = self.clean_mode
762 # Auto-detect code type from namespace variables
763 from openhcs.debug.pickle_to_python import (
764 generate_complete_orchestrator_code,
765 generate_complete_pipeline_steps_code,
766 generate_complete_function_pattern_code,
767 generate_config_code
768 )
770 # Check what variables exist in the namespace to determine code type
771 if 'plate_paths' in namespace or 'pipeline_data' in namespace:
772 # Orchestrator code
773 plate_paths = namespace.get('plate_paths', [])
774 pipeline_data = namespace.get('pipeline_data', {})
775 global_config = namespace.get('global_config')
776 per_plate_configs = namespace.get('per_plate_configs')
777 pipeline_config = namespace.get('pipeline_config')
779 new_code = generate_complete_orchestrator_code(
780 plate_paths=plate_paths,
781 pipeline_data=pipeline_data,
782 global_config=global_config,
783 per_plate_configs=per_plate_configs,
784 pipeline_config=pipeline_config,
785 clean_mode=self.clean_mode
786 )
787 elif 'pipeline_steps' in namespace:
788 # Pipeline steps code
789 pipeline_steps = namespace.get('pipeline_steps', [])
791 new_code = generate_complete_pipeline_steps_code(
792 pipeline_steps=pipeline_steps,
793 clean_mode=self.clean_mode
794 )
795 elif 'pattern' in namespace:
796 # Function pattern code (uses 'pattern' variable name)
797 pattern = namespace.get('pattern')
799 new_code = generate_complete_function_pattern_code(
800 func_obj=pattern,
801 clean_mode=self.clean_mode
802 )
803 elif 'config' in namespace:
804 # Config code - auto-detect config class from the object
805 config = namespace.get('config')
806 config_class = type(config)
808 new_code = generate_config_code(
809 config=config,
810 config_class=config_class,
811 clean_mode=self.clean_mode
812 )
813 else:
814 # Unsupported code type
815 from PyQt6.QtWidgets import QMessageBox
816 QMessageBox.warning(self, "Clean Mode Toggle",
817 "Could not detect code type. Expected one of: plate_paths, pipeline_steps, pattern, or config variable.")
818 return
820 # Update editor with new code
821 self.editor.setText(new_code)
823 except Exception as e:
824 from PyQt6.QtWidgets import QMessageBox
825 QMessageBox.critical(self, "Clean Mode Toggle Error",
826 f"Failed to toggle clean mode: {str(e)}")
828 def get_content(self) -> str:
829 """Get the edited content."""
830 return self.editor.text()
832 def _handle_save(self) -> None:
833 """
834 Handle save button click - validate code before closing.
835 Only closes dialog if callback succeeds, otherwise shows error and keeps dialog open.
836 """
837 logger.info("Save button clicked")
839 if self.callback is None:
840 # No callback, just close
841 logger.info("No callback, closing dialog")
842 self.accept()
843 return
845 edited_content = self.get_content()
847 try:
848 # Try to execute the callback
849 logger.info("Executing callback...")
850 self.callback(edited_content)
851 # Success - close the dialog
852 logger.info("Callback succeeded, closing dialog")
853 self.accept()
855 except Exception as e:
856 # Error - extract line number and show error
857 logger.error(f"Callback failed with error: {e}")
858 error_line = self._extract_error_line(e)
859 logger.info(f"Extracted error line: {error_line}")
861 # Show error message
862 error_msg = str(e)
863 if error_line:
864 error_msg = f"Line {error_line}: {error_msg}"
866 from PyQt6.QtWidgets import QMessageBox
867 QMessageBox.critical(self, "Error Parsing Code", error_msg)
869 # Move cursor to error line
870 if error_line:
871 logger.info(f"Moving cursor to line {error_line}")
872 self._goto_line(error_line)
874 logger.info("Keeping dialog open for user to fix error")
875 # Keep dialog open - user can fix the error or click Cancel
876 # Do NOT call self.accept() or self.reject() here
878 def _extract_error_line(self, exception: Exception) -> Optional[int]:
879 """Extract line number from exception if available."""
880 # For SyntaxError, use lineno attribute
881 if isinstance(exception, SyntaxError) and hasattr(exception, 'lineno'):
882 return exception.lineno
884 # For other exceptions, try to extract from traceback
885 import traceback
886 import sys
887 tb = sys.exc_info()[2]
888 if tb:
889 # Find the frame that executed the user's code (marked as '<string>')
890 for frame_summary in traceback.extract_tb(tb):
891 if '<string>' in frame_summary.filename:
892 return frame_summary.lineno
894 return None
896 def _goto_line(self, line_number: int) -> None:
897 """
898 Move cursor to specified line and highlight it.
900 Args:
901 line_number: Line number to go to (1-based)
902 """
903 if line_number is None or line_number < 1:
904 return
906 # Convert to 0-based line number for QScintilla
907 line_index = line_number - 1
909 # Move cursor to the line
910 self.editor.setCursorPosition(line_index, 0)
912 # Ensure the line is visible
913 self.editor.ensureLineVisible(line_index)
915 # Select the entire line to highlight it
916 line_length = self.editor.lineLength(line_index)
917 self.editor.setSelection(line_index, 0, line_index, line_length)
920class CodeEditorDialog(QDialog):
921 """
922 Fallback Qt native code editor dialog using QTextEdit.
924 Used when QScintilla is not available.
925 """
927 def __init__(self, parent, initial_content: str, title: str):
928 super().__init__(parent)
929 self.setWindowTitle(title)
930 self.setModal(True)
931 self.resize(800, 600)
933 # Setup UI
934 layout = QVBoxLayout(self)
936 # Text editor
937 self.text_edit = QTextEdit()
938 self.text_edit.setPlainText(initial_content)
940 # Use monospace font for code
941 font = QFont("Consolas", 10)
942 if not font.exactMatch():
943 font = QFont("Courier New", 10)
944 self.text_edit.setFont(font)
946 layout.addWidget(self.text_edit)
948 # Buttons
949 button_layout = QHBoxLayout()
951 save_btn = QPushButton("Save")
952 save_btn.clicked.connect(self.accept)
953 button_layout.addWidget(save_btn)
955 cancel_btn = QPushButton("Cancel")
956 cancel_btn.clicked.connect(self.reject)
957 button_layout.addWidget(cancel_btn)
959 button_layout.addStretch()
960 layout.addLayout(button_layout)
962 # Focus on text editor
963 self.text_edit.setFocus()
965 def get_content(self) -> str:
966 """Get the edited content."""
967 return self.text_edit.toPlainText()