Coverage for openhcs/pyqt_gui/services/simple_code_editor.py: 0.0%

268 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +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, QMenu, 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) -> None: 

54 """ 

55 Edit code using either Qt native editor or external program. 

56  

57 Args: 

58 initial_content: Initial code content 

59 title: Editor window title 

60 callback: Callback function called with edited content 

61 use_external: If True, use external editor; if False, use Qt native 

62 """ 

63 if use_external: 

64 self._edit_with_external_program(initial_content, callback) 

65 else: 

66 self._edit_with_qt_native(initial_content, title, callback) 

67 

68 def _edit_with_qt_native(self, initial_content: str, title: str, 

69 callback: Optional[Callable[[str], None]]) -> None: 

70 """Edit code using Qt native text editor dialog (QScintilla preferred).""" 

71 try: 

72 if QSCINTILLA_AVAILABLE: 

73 logger.debug("Using QScintilla editor for code editing") 

74 dialog = QScintillaCodeEditorDialog(self.parent, initial_content, title) 

75 else: 

76 logger.debug("QScintilla not available, using QTextEdit fallback") 

77 dialog = CodeEditorDialog(self.parent, initial_content, title) 

78 

79 if dialog.exec() == QDialog.DialogCode.Accepted: 

80 edited_content = dialog.get_content() 

81 if callback: 

82 callback(edited_content) 

83 

84 except Exception as e: 

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

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

87 

88 def _edit_with_external_program(self, initial_content: str, 

89 callback: Optional[Callable[[str], None]]) -> None: 

90 """Edit code using external program (vim, nano, vscode, etc.).""" 

91 try: 

92 # Create temporary file 

93 with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: 

94 f.write(initial_content) 

95 temp_path = Path(f.name) 

96 

97 # Get editor from environment or use default 

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

99 

100 # Launch editor and wait for completion 

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

102 capture_output=False, 

103 text=True) 

104 

105 if result.returncode == 0: 

106 # Read edited content 

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

108 edited_content = f.read() 

109 

110 if callback: 

111 callback(edited_content) 

112 else: 

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

114 

115 except FileNotFoundError: 

116 self._show_error(f"Editor '{editor}' not found. Set EDITOR environment variable or install nano/vim.") 

117 except Exception as e: 

118 logger.error(f"External editor failed: {e}") 

119 self._show_error(f"External editor failed: {str(e)}") 

120 finally: 

121 # Clean up temporary file 

122 try: 

123 if temp_path.exists(): 

124 temp_path.unlink() 

125 except: 

126 pass 

127 

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

129 """Show error message to user.""" 

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

131 

132 

133class QScintillaCodeEditorDialog(QDialog): 

134 """ 

135 Professional code editor dialog using QScintilla. 

136 

137 Provides Python syntax highlighting, code folding, line numbers, and more. 

138 Integrates with PyQt6ColorScheme for consistent theming. 

139 """ 

140 

141 def __init__(self, parent, initial_content: str, title: str): 

142 super().__init__(parent) 

143 self.setWindowTitle(title) 

144 self.setModal(True) 

145 self.resize(900, 700) 

146 

147 # Get color scheme from parent 

148 from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

149 self.color_scheme = PyQt6ColorScheme() 

150 

151 # Setup UI 

152 self._setup_ui(initial_content) 

153 

154 # Apply theming 

155 self._apply_theme() 

156 

157 # Focus on editor 

158 self.editor.setFocus() 

159 

160 

161 

162 def _setup_ui(self, initial_content: str): 

163 """Setup the UI components.""" 

164 # Main layout 

165 main_layout = QVBoxLayout(self) 

166 

167 # Menu bar 

168 self._setup_menu_bar() 

169 main_layout.addWidget(self.menu_bar) 

170 

171 # QScintilla editor 

172 self.editor = QsciScintilla() 

173 self.editor.setText(initial_content) 

174 

175 # Set Python lexer for syntax highlighting 

176 self.lexer = QsciLexerPython() 

177 self.editor.setLexer(self.lexer) 

178 

179 # Configure editor features 

180 self._configure_editor() 

181 

182 main_layout.addWidget(self.editor) 

183 

184 # Buttons 

185 button_layout = QHBoxLayout() 

186 

187 self.save_btn = QPushButton("Save") 

188 self.save_btn.clicked.connect(self.accept) 

189 button_layout.addWidget(self.save_btn) 

190 

191 self.cancel_btn = QPushButton("Cancel") 

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

193 button_layout.addWidget(self.cancel_btn) 

194 

195 button_layout.addStretch() 

196 main_layout.addLayout(button_layout) 

197 

198 def _setup_menu_bar(self): 

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

200 from PyQt6.QtWidgets import QMenuBar 

201 

202 self.menu_bar = QMenuBar(self) 

203 

204 # File menu 

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

206 

207 # New action 

208 new_action = QAction("&New", self) 

209 new_action.setShortcut(QKeySequence.StandardKey.New) 

210 new_action.triggered.connect(self._new_file) 

211 file_menu.addAction(new_action) 

212 

213 # Open action 

214 open_action = QAction("&Open...", self) 

215 open_action.setShortcut(QKeySequence.StandardKey.Open) 

216 open_action.triggered.connect(self._open_file) 

217 file_menu.addAction(open_action) 

218 

219 file_menu.addSeparator() 

220 

221 # Save action 

222 save_action = QAction("&Save", self) 

223 save_action.setShortcut(QKeySequence.StandardKey.Save) 

224 save_action.triggered.connect(self.accept) 

225 file_menu.addAction(save_action) 

226 

227 # Save As action 

228 save_as_action = QAction("Save &As...", self) 

229 save_as_action.setShortcut(QKeySequence.StandardKey.SaveAs) 

230 save_as_action.triggered.connect(self._save_as) 

231 file_menu.addAction(save_as_action) 

232 

233 file_menu.addSeparator() 

234 

235 # Close action 

236 close_action = QAction("&Close", self) 

237 close_action.setShortcut(QKeySequence.StandardKey.Close) 

238 close_action.triggered.connect(self.reject) 

239 file_menu.addAction(close_action) 

240 

241 # Edit menu 

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

243 

244 # Undo action 

245 undo_action = QAction("&Undo", self) 

246 undo_action.setShortcut(QKeySequence.StandardKey.Undo) 

247 undo_action.triggered.connect(lambda: self.editor.undo()) 

248 edit_menu.addAction(undo_action) 

249 

250 # Redo action 

251 redo_action = QAction("&Redo", self) 

252 redo_action.setShortcut(QKeySequence.StandardKey.Redo) 

253 redo_action.triggered.connect(lambda: self.editor.redo()) 

254 edit_menu.addAction(redo_action) 

255 

256 edit_menu.addSeparator() 

257 

258 # Cut, Copy, Paste 

259 cut_action = QAction("Cu&t", self) 

260 cut_action.setShortcut(QKeySequence.StandardKey.Cut) 

261 cut_action.triggered.connect(lambda: self.editor.cut()) 

262 edit_menu.addAction(cut_action) 

263 

264 copy_action = QAction("&Copy", self) 

265 copy_action.setShortcut(QKeySequence.StandardKey.Copy) 

266 copy_action.triggered.connect(lambda: self.editor.copy()) 

267 edit_menu.addAction(copy_action) 

268 

269 paste_action = QAction("&Paste", self) 

270 paste_action.setShortcut(QKeySequence.StandardKey.Paste) 

271 paste_action.triggered.connect(lambda: self.editor.paste()) 

272 edit_menu.addAction(paste_action) 

273 

274 edit_menu.addSeparator() 

275 

276 # Select All 

277 select_all_action = QAction("Select &All", self) 

278 select_all_action.setShortcut(QKeySequence.StandardKey.SelectAll) 

279 select_all_action.triggered.connect(lambda: self.editor.selectAll()) 

280 edit_menu.addAction(select_all_action) 

281 

282 # View menu 

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

284 

285 # Toggle line numbers 

286 toggle_line_numbers = QAction("Toggle &Line Numbers", self) 

287 toggle_line_numbers.setCheckable(True) 

288 toggle_line_numbers.setChecked(True) 

289 toggle_line_numbers.triggered.connect(self._toggle_line_numbers) 

290 view_menu.addAction(toggle_line_numbers) 

291 

292 # Toggle code folding 

293 toggle_folding = QAction("Toggle Code &Folding", self) 

294 toggle_folding.setCheckable(True) 

295 toggle_folding.setChecked(True) 

296 toggle_folding.triggered.connect(self._toggle_code_folding) 

297 view_menu.addAction(toggle_folding) 

298 

299 def _configure_editor(self): 

300 """Configure QScintilla editor with professional features.""" 

301 # Line numbers 

302 self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin) 

303 self.editor.setMarginWidth(0, "0000") 

304 self.editor.setMarginLineNumbers(0, True) 

305 self.editor.setMarginsBackgroundColor(Qt.GlobalColor.lightGray) 

306 

307 # Current line highlighting 

308 self.editor.setCaretLineVisible(True) 

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

310 

311 # Set font 

312 font = QFont("Consolas", 10) 

313 if not font.exactMatch(): 

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

315 self.editor.setFont(font) 

316 

317 # Indentation 

318 self.editor.setIndentationsUseTabs(False) 

319 self.editor.setIndentationWidth(4) 

320 self.editor.setTabWidth(4) 

321 self.editor.setAutoIndent(True) 

322 

323 # Code folding 

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

325 

326 # Brace matching 

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

328 

329 # Selection 

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

331 

332 # Enable UTF-8 

333 self.editor.setUtf8(True) 

334 

335 def _apply_theme(self): 

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

337 cs = self.color_scheme 

338 

339 # Apply dialog styling 

340 self.setStyleSheet(f""" 

341 QDialog {{ 

342 background-color: {cs.to_hex(cs.window_bg)}; 

343 color: {cs.to_hex(cs.text_primary)}; 

344 }} 

345 QPushButton {{ 

346 background-color: {cs.to_hex(cs.button_normal_bg)}; 

347 color: {cs.to_hex(cs.button_text)}; 

348 border: 1px solid {cs.to_hex(cs.border_light)}; 

349 border-radius: 3px; 

350 padding: 5px; 

351 min-width: 80px; 

352 }} 

353 QPushButton:hover {{ 

354 background-color: {cs.to_hex(cs.button_hover_bg)}; 

355 }} 

356 QPushButton:pressed {{ 

357 background-color: {cs.to_hex(cs.button_pressed_bg)}; 

358 }} 

359 QMenuBar {{ 

360 background-color: {cs.to_hex(cs.panel_bg)}; 

361 color: {cs.to_hex(cs.text_primary)}; 

362 border-bottom: 1px solid {cs.to_hex(cs.border_color)}; 

363 }} 

364 QMenuBar::item {{ 

365 background-color: transparent; 

366 padding: 4px 8px; 

367 }} 

368 QMenuBar::item:selected {{ 

369 background-color: {cs.to_hex(cs.button_hover_bg)}; 

370 }} 

371 QMenu {{ 

372 background-color: {cs.to_hex(cs.panel_bg)}; 

373 color: {cs.to_hex(cs.text_primary)}; 

374 border: 1px solid {cs.to_hex(cs.border_color)}; 

375 }} 

376 QMenu::item {{ 

377 padding: 4px 20px; 

378 }} 

379 QMenu::item:selected {{ 

380 background-color: {cs.to_hex(cs.button_hover_bg)}; 

381 }} 

382 """) 

383 

384 # Apply QScintilla-specific theming 

385 self._apply_qscintilla_theme() 

386 

387 def _apply_qscintilla_theme(self): 

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

389 cs = self.color_scheme 

390 

391 # Set editor background and text colors 

392 self.editor.setColor(cs.to_qcolor(cs.text_primary)) 

393 self.editor.setPaper(cs.to_qcolor(cs.panel_bg)) 

394 

395 # Set margin colors 

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

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

398 

399 # Set caret line color 

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

401 

402 # Set selection colors 

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

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

405 

406 # Configure Python lexer colors 

407 if self.lexer: 

408 # Keywords (def, class, if, etc.) 

409 self.lexer.setColor(cs.to_qcolor(cs.python_keyword_color), QsciLexerPython.Keyword) 

410 

411 # Strings 

412 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.SingleQuotedString) 

413 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.DoubleQuotedString) 

414 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleSingleQuotedString) 

415 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleDoubleQuotedString) 

416 

417 # F-strings 

418 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.SingleQuotedFString) 

419 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.DoubleQuotedFString) 

420 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleSingleQuotedFString) 

421 self.lexer.setColor(cs.to_qcolor(cs.python_string_color), QsciLexerPython.TripleDoubleQuotedFString) 

422 

423 # Comments 

424 self.lexer.setColor(cs.to_qcolor(cs.python_comment_color), QsciLexerPython.Comment) 

425 self.lexer.setColor(cs.to_qcolor(cs.python_comment_color), QsciLexerPython.CommentBlock) 

426 

427 # Numbers 

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

429 

430 # Functions and classes 

431 self.lexer.setColor(cs.to_qcolor(cs.python_function_color), QsciLexerPython.FunctionMethodName) 

432 self.lexer.setColor(cs.to_qcolor(cs.python_class_color), QsciLexerPython.ClassName) 

433 

434 # Operators 

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

436 

437 # Identifiers 

438 self.lexer.setColor(cs.to_qcolor(cs.python_name_color), QsciLexerPython.Identifier) 

439 self.lexer.setColor(cs.to_qcolor(cs.python_name_color), QsciLexerPython.HighlightedIdentifier) 

440 

441 # Decorators 

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

443 

444 # Set default background for lexer 

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

446 

447 # Menu action handlers 

448 def _new_file(self): 

449 """Clear editor content.""" 

450 self.editor.clear() 

451 

452 def _open_file(self): 

453 """Open file dialog and load content.""" 

454 file_path, _ = QFileDialog.getOpenFileName( 

455 self, "Open Python File", "", "Python Files (*.py);;All Files (*)" 

456 ) 

457 if file_path: 

458 try: 

459 with open(file_path, 'r', encoding='utf-8') as f: 

460 content = f.read() 

461 self.editor.setText(content) 

462 except Exception as e: 

463 QMessageBox.critical(self, "Error", f"Failed to open file: {str(e)}") 

464 

465 def _save_as(self): 

466 """Save content to file.""" 

467 file_path, _ = QFileDialog.getSaveFileName( 

468 self, "Save Python File", "", "Python Files (*.py);;All Files (*)" 

469 ) 

470 if file_path: 

471 try: 

472 with open(file_path, 'w', encoding='utf-8') as f: 

473 f.write(self.editor.text()) 

474 QMessageBox.information(self, "Success", f"File saved to {file_path}") 

475 except Exception as e: 

476 QMessageBox.critical(self, "Error", f"Failed to save file: {str(e)}") 

477 

478 def _toggle_line_numbers(self, checked): 

479 """Toggle line number display.""" 

480 if checked: 

481 self.editor.setMarginType(0, QsciScintilla.MarginType.NumberMargin) 

482 self.editor.setMarginWidth(0, "0000") 

483 self.editor.setMarginLineNumbers(0, True) 

484 else: 

485 self.editor.setMarginWidth(0, 0) 

486 

487 def _toggle_code_folding(self, checked): 

488 """Toggle code folding.""" 

489 if checked: 

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

491 else: 

492 self.editor.setFolding(QsciScintilla.FoldStyle.NoFoldStyle) 

493 

494 def get_content(self) -> str: 

495 """Get the edited content.""" 

496 return self.editor.text() 

497 

498 

499class CodeEditorDialog(QDialog): 

500 """ 

501 Fallback Qt native code editor dialog using QTextEdit. 

502 

503 Used when QScintilla is not available. 

504 """ 

505 

506 def __init__(self, parent, initial_content: str, title: str): 

507 super().__init__(parent) 

508 self.setWindowTitle(title) 

509 self.setModal(True) 

510 self.resize(800, 600) 

511 

512 # Setup UI 

513 layout = QVBoxLayout(self) 

514 

515 # Text editor 

516 self.text_edit = QTextEdit() 

517 self.text_edit.setPlainText(initial_content) 

518 

519 # Use monospace font for code 

520 font = QFont("Consolas", 10) 

521 if not font.exactMatch(): 

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

523 self.text_edit.setFont(font) 

524 

525 layout.addWidget(self.text_edit) 

526 

527 # Buttons 

528 button_layout = QHBoxLayout() 

529 

530 save_btn = QPushButton("Save") 

531 save_btn.clicked.connect(self.accept) 

532 button_layout.addWidget(save_btn) 

533 

534 cancel_btn = QPushButton("Cancel") 

535 cancel_btn.clicked.connect(self.reject) 

536 button_layout.addWidget(cancel_btn) 

537 

538 button_layout.addStretch() 

539 layout.addLayout(button_layout) 

540 

541 # Focus on text editor 

542 self.text_edit.setFocus() 

543 

544 def get_content(self) -> str: 

545 """Get the edited content.""" 

546 return self.text_edit.toPlainText()