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

1""" 

2Simple Code Editor Service for PyQt GUI. 

3 

4Provides modular text editing with QScintilla (default) or external program launch. 

5No threading complications - keeps it simple and direct. 

6""" 

7 

8import logging 

9import tempfile 

10import os 

11import subprocess 

12from pathlib import Path 

13from typing import Optional, Callable 

14 

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 

19 

20logger = logging.getLogger(__name__) 

21 

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 

31 

32 

33class SimpleCodeEditorService: 

34 """ 

35 Simple, modular code editor service. 

36 

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 """ 

41 

42 def __init__(self, parent_widget): 

43 """ 

44 Initialize the code editor service. 

45 

46 Args: 

47 parent_widget: Parent widget for dialogs 

48 """ 

49 self.parent = parent_widget 

50 

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. 

58 

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) 

71 

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) 

88 

89 # Execute dialog - callback is now handled inside the dialog 

90 dialog.exec() 

91 

92 except Exception as e: 

93 logger.error(f"Qt native editor failed: {e}") 

94 self._show_error(f"Editor failed: {str(e)}") 

95 

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) 

104 

105 # Get editor from environment or use default 

106 editor = os.environ.get('EDITOR', 'nano') 

107 

108 # Launch editor and wait for completion 

109 result = subprocess.run([editor, str(temp_path)], 

110 capture_output=False, 

111 text=True) 

112 

113 if result.returncode == 0: 

114 # Read edited content 

115 with open(temp_path, 'r') as f: 

116 edited_content = f.read() 

117 

118 if callback: 

119 callback(edited_content) 

120 else: 

121 self._show_error(f"Editor exited with code {result.returncode}") 

122 

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 

135 

136 def _show_error(self, message: str) -> None: 

137 """Show error message to user.""" 

138 QMessageBox.critical(self.parent, "Editor Error", message) 

139 

140 

141class QScintillaCodeEditorDialog(QDialog): 

142 """ 

143 Professional code editor dialog using QScintilla. 

144 

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 """ 

149 

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. 

155 

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) 

169 

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 

176 

177 # Get color scheme from parent 

178 from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

179 self.color_scheme = PyQt6ColorScheme() 

180 

181 # Setup UI 

182 self._setup_ui(initial_content) 

183 

184 # Apply theming 

185 self._apply_theme() 

186 

187 # Move cursor to error line if specified 

188 if self.initial_line is not None: 

189 self._goto_line(self.initial_line) 

190 

191 # Focus on editor 

192 self.editor.setFocus() 

193 

194 

195 

196 def _setup_ui(self, initial_content: str): 

197 """Setup the UI components.""" 

198 # Main layout 

199 main_layout = QVBoxLayout(self) 

200 

201 # Menu bar 

202 self._setup_menu_bar() 

203 main_layout.addWidget(self.menu_bar) 

204 

205 # QScintilla editor 

206 self.editor = QsciScintilla() 

207 self.editor.setText(initial_content) 

208 

209 # Set Python lexer for syntax highlighting 

210 self.lexer = QsciLexerPython() 

211 self.editor.setLexer(self.lexer) 

212 

213 # Configure editor features 

214 self._configure_editor() 

215 

216 main_layout.addWidget(self.editor) 

217 

218 # Buttons 

219 button_layout = QHBoxLayout() 

220 

221 self.save_btn = QPushButton("Save") 

222 self.save_btn.clicked.connect(self._handle_save) 

223 button_layout.addWidget(self.save_btn) 

224 

225 self.cancel_btn = QPushButton("Cancel") 

226 self.cancel_btn.clicked.connect(self.reject) 

227 button_layout.addWidget(self.cancel_btn) 

228 

229 button_layout.addStretch() 

230 main_layout.addLayout(button_layout) 

231 

232 def _setup_menu_bar(self): 

233 """Setup menu bar with File, Edit, View menus.""" 

234 

235 self.menu_bar = QMenuBar(self) 

236 

237 # File menu 

238 file_menu = self.menu_bar.addMenu("&File") 

239 

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) 

245 

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) 

251 

252 file_menu.addSeparator() 

253 

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) 

259 

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) 

265 

266 file_menu.addSeparator() 

267 

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) 

273 

274 # Edit menu 

275 edit_menu = self.menu_bar.addMenu("&Edit") 

276 

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) 

282 

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) 

288 

289 edit_menu.addSeparator() 

290 

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) 

296 

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) 

301 

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) 

306 

307 edit_menu.addSeparator() 

308 

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) 

314 

315 # View menu 

316 view_menu = self.menu_bar.addMenu("&View") 

317 

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) 

324 

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) 

331 

332 # Add separator before clean mode toggle 

333 view_menu.addSeparator() 

334 

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) 

341 

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) 

349 

350 # Current line highlighting 

351 self.editor.setCaretLineVisible(True) 

352 self.editor.setCaretLineBackgroundColor(Qt.GlobalColor.lightGray) 

353 

354 # Set font 

355 font = QFont("Consolas", 10) 

356 if not font.exactMatch(): 

357 font = QFont("Courier New", 10) 

358 self.editor.setFont(font) 

359 

360 # Indentation 

361 self.editor.setIndentationsUseTabs(False) 

362 self.editor.setIndentationWidth(4) 

363 self.editor.setTabWidth(4) 

364 self.editor.setAutoIndent(True) 

365 

366 # Code folding 

367 self.editor.setFolding(QsciScintilla.FoldStyle.BoxedTreeFoldStyle) 

368 

369 # Brace matching 

370 self.editor.setBraceMatching(QsciScintilla.BraceMatch.SloppyBraceMatch) 

371 

372 # Selection 

373 self.editor.setSelectionBackgroundColor(Qt.GlobalColor.blue) 

374 

375 # Enable UTF-8 

376 self.editor.setUtf8(True) 

377 

378 # Configure autocomplete 

379 self._configure_autocomplete() 

380 

381 def _configure_autocomplete(self): 

382 """Configure autocomplete for Python code.""" 

383 logger.info("🔧 Configuring Jedi-powered autocomplete...") 

384 

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") 

388 

389 # Show autocomplete after typing 2 characters 

390 self.editor.setAutoCompletionThreshold(2) 

391 logger.info(" ✓ Autocomplete threshold: 2 characters") 

392 

393 # Case-insensitive matching 

394 self.editor.setAutoCompletionCaseSensitivity(False) 

395 

396 # Replace word when selecting from autocomplete 

397 self.editor.setAutoCompletionReplaceWord(True) 

398 

399 # Show single item automatically 

400 self.editor.setAutoCompletionUseSingle(QsciScintilla.AutoCompletionUseSingle.AcusNever) 

401 

402 # Note: setAutoCompletionMaxVisibleItems() doesn't exist in QScintilla 

403 # The list size is automatically managed 

404 

405 # Setup Jedi-based API 

406 self._setup_jedi_api() 

407 

408 # Install event filter to catch key presses for autocomplete triggering 

409 self.editor.installEventFilter(self) 

410 logger.info(" ✓ Event filter installed for '.' trigger") 

411 

412 def eventFilter(self, obj, event): 

413 """Filter events to trigger Jedi autocomplete on '.' """ 

414 from PyQt6.QtCore import QEvent 

415 

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) 

424 

425 return super().eventFilter(obj, event) 

426 

427 def _setup_jedi_api(self): 

428 """Setup initial API with basic Python keywords for fallback.""" 

429 try: 

430 from PyQt6.Qsci import QsciAPIs 

431 

432 # Create API object for Python lexer 

433 self.api = QsciAPIs(self.lexer) 

434 logger.info(" ✓ Created QsciAPIs object") 

435 

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 ] 

445 

446 for keyword in python_keywords: 

447 self.api.add(keyword) 

448 

449 # Prepare the API 

450 self.api.prepare() 

451 

452 logger.info(f" ✓ Added {len(python_keywords)} Python keywords to API") 

453 

454 except Exception as e: 

455 logger.error(f"❌ Failed to setup Jedi API autocomplete: {e}") 

456 

457 def _show_jedi_completions(self): 

458 """Show Jedi-powered autocomplete suggestions.""" 

459 logger.info("🧠 _show_jedi_completions called") 

460 try: 

461 import jedi 

462 

463 # Get current code and cursor position 

464 code = self.editor.text() 

465 line, col = self.editor.getCursorPosition() 

466 

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") 

472 

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 

496 

497 # jedi_line already set above based on whether we added import 

498 jedi_col = col 

499 

500 # Create Jedi script with current code 

501 # Use project parameter to tell Jedi where to find openhcs modules 

502 import os 

503 

504 # Get project root (where openhcs package is) 

505 project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) 

506 

507 # Create Jedi project 

508 project = jedi.Project(path=project_root) 

509 

510 script = jedi.Script(code, path='<editor>', project=project) 

511 logger.info(f" ✓ Created Jedi script with project root: {project_root}") 

512 

513 # Get completions at cursor position 

514 completions = script.complete(jedi_line, jedi_col) 

515 logger.info(f" 🔍 Jedi returned {len(completions)} completions") 

516 

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}") 

521 

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}") 

526 

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() 

531 

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) 

540 

541 logger.info(f" 📝 Built {len(completion_items)} completion items") 

542 

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") 

547 

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") 

553 

554 else: 

555 logger.info(" ⚠️ No Jedi completions - trying standard autocomplete") 

556 # No Jedi completions, try standard autocomplete 

557 self.editor.autoCompleteFromAll() 

558 

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 

566 

567 def _apply_theme(self): 

568 """Apply PyQt6ColorScheme theming to QScintilla editor.""" 

569 cs = self.color_scheme 

570 

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 """) 

615 

616 # Apply QScintilla-specific theming 

617 self._apply_qscintilla_theme() 

618 

619 def _apply_qscintilla_theme(self): 

620 """Apply color scheme to QScintilla editor and lexer.""" 

621 cs = self.color_scheme 

622 

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)) 

626 

627 # Set margin colors 

628 self.editor.setMarginsBackgroundColor(cs.to_qcolor(cs.frame_bg)) 

629 self.editor.setMarginsForegroundColor(cs.to_qcolor(cs.text_secondary)) 

630 

631 # Set caret line color 

632 self.editor.setCaretLineBackgroundColor(cs.to_qcolor(cs.selection_bg)) 

633 

634 # Set selection colors 

635 self.editor.setSelectionBackgroundColor(cs.to_qcolor(cs.selection_bg)) 

636 self.editor.setSelectionForegroundColor(cs.to_qcolor(cs.selection_text)) 

637 

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) 

642 

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) 

648 

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) 

654 

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) 

658 

659 # Numbers 

660 self.lexer.setColor(cs.to_qcolor(cs.python_number_color), QsciLexerPython.Number) 

661 

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) 

665 

666 # Operators 

667 self.lexer.setColor(cs.to_qcolor(cs.python_operator_color), QsciLexerPython.Operator) 

668 

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) 

672 

673 # Decorators 

674 self.lexer.setColor(cs.to_qcolor(cs.python_function_color), QsciLexerPython.Decorator) 

675 

676 # Set default background for lexer 

677 self.lexer.setPaper(cs.to_qcolor(cs.panel_bg)) 

678 

679 # Menu action handlers 

680 def _new_file(self): 

681 """Clear editor content.""" 

682 self.editor.clear() 

683 

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 

687 

688 # Get cached initial directory 

689 initial_dir = str(get_cached_dialog_path(PathCacheKey.CODE_EDITOR, fallback=Path.home())) 

690 

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) 

699 

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)}") 

705 

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 

709 

710 # Get cached initial directory 

711 initial_dir = str(get_cached_dialog_path(PathCacheKey.CODE_EDITOR, fallback=Path.home())) 

712 

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) 

719 

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) 

724 

725 # Cache the parent directory for future dialogs 

726 cache_dialog_path(PathCacheKey.CODE_EDITOR, selected_path.parent) 

727 

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)}") 

733 

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) 

742 

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) 

749 

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) 

757 

758 # Toggle clean mode 

759 self.clean_mode = checked 

760 self.code_data['clean_mode'] = self.clean_mode 

761 

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 ) 

769 

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') 

778 

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', []) 

790 

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') 

798 

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) 

807 

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 

819 

820 # Update editor with new code 

821 self.editor.setText(new_code) 

822 

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)}") 

827 

828 def get_content(self) -> str: 

829 """Get the edited content.""" 

830 return self.editor.text() 

831 

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") 

838 

839 if self.callback is None: 

840 # No callback, just close 

841 logger.info("No callback, closing dialog") 

842 self.accept() 

843 return 

844 

845 edited_content = self.get_content() 

846 

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() 

854 

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}") 

860 

861 # Show error message 

862 error_msg = str(e) 

863 if error_line: 

864 error_msg = f"Line {error_line}: {error_msg}" 

865 

866 from PyQt6.QtWidgets import QMessageBox 

867 QMessageBox.critical(self, "Error Parsing Code", error_msg) 

868 

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) 

873 

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 

877 

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 

883 

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 

893 

894 return None 

895 

896 def _goto_line(self, line_number: int) -> None: 

897 """ 

898 Move cursor to specified line and highlight it. 

899 

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 

905 

906 # Convert to 0-based line number for QScintilla 

907 line_index = line_number - 1 

908 

909 # Move cursor to the line 

910 self.editor.setCursorPosition(line_index, 0) 

911 

912 # Ensure the line is visible 

913 self.editor.ensureLineVisible(line_index) 

914 

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) 

918 

919 

920class CodeEditorDialog(QDialog): 

921 """ 

922 Fallback Qt native code editor dialog using QTextEdit. 

923 

924 Used when QScintilla is not available. 

925 """ 

926 

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) 

932 

933 # Setup UI 

934 layout = QVBoxLayout(self) 

935 

936 # Text editor 

937 self.text_edit = QTextEdit() 

938 self.text_edit.setPlainText(initial_content) 

939 

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) 

945 

946 layout.addWidget(self.text_edit) 

947 

948 # Buttons 

949 button_layout = QHBoxLayout() 

950 

951 save_btn = QPushButton("Save") 

952 save_btn.clicked.connect(self.accept) 

953 button_layout.addWidget(save_btn) 

954 

955 cancel_btn = QPushButton("Cancel") 

956 cancel_btn.clicked.connect(self.reject) 

957 button_layout.addWidget(cancel_btn) 

958 

959 button_layout.addStretch() 

960 layout.addLayout(button_layout) 

961 

962 # Focus on text editor 

963 self.text_edit.setFocus() 

964 

965 def get_content(self) -> str: 

966 """Get the edited content.""" 

967 return self.text_edit.toPlainText()