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
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +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, QMenu, 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) -> None:
54 """
55 Edit code using either Qt native editor or external program.
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)
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)
79 if dialog.exec() == QDialog.DialogCode.Accepted:
80 edited_content = dialog.get_content()
81 if callback:
82 callback(edited_content)
84 except Exception as e:
85 logger.error(f"Qt native editor failed: {e}")
86 self._show_error(f"Editor failed: {str(e)}")
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)
97 # Get editor from environment or use default
98 editor = os.environ.get('EDITOR', 'nano')
100 # Launch editor and wait for completion
101 result = subprocess.run([editor, str(temp_path)],
102 capture_output=False,
103 text=True)
105 if result.returncode == 0:
106 # Read edited content
107 with open(temp_path, 'r') as f:
108 edited_content = f.read()
110 if callback:
111 callback(edited_content)
112 else:
113 self._show_error(f"Editor exited with code {result.returncode}")
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
128 def _show_error(self, message: str) -> None:
129 """Show error message to user."""
130 QMessageBox.critical(self.parent, "Editor Error", message)
133class QScintillaCodeEditorDialog(QDialog):
134 """
135 Professional code editor dialog using QScintilla.
137 Provides Python syntax highlighting, code folding, line numbers, and more.
138 Integrates with PyQt6ColorScheme for consistent theming.
139 """
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)
147 # Get color scheme from parent
148 from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
149 self.color_scheme = PyQt6ColorScheme()
151 # Setup UI
152 self._setup_ui(initial_content)
154 # Apply theming
155 self._apply_theme()
157 # Focus on editor
158 self.editor.setFocus()
162 def _setup_ui(self, initial_content: str):
163 """Setup the UI components."""
164 # Main layout
165 main_layout = QVBoxLayout(self)
167 # Menu bar
168 self._setup_menu_bar()
169 main_layout.addWidget(self.menu_bar)
171 # QScintilla editor
172 self.editor = QsciScintilla()
173 self.editor.setText(initial_content)
175 # Set Python lexer for syntax highlighting
176 self.lexer = QsciLexerPython()
177 self.editor.setLexer(self.lexer)
179 # Configure editor features
180 self._configure_editor()
182 main_layout.addWidget(self.editor)
184 # Buttons
185 button_layout = QHBoxLayout()
187 self.save_btn = QPushButton("Save")
188 self.save_btn.clicked.connect(self.accept)
189 button_layout.addWidget(self.save_btn)
191 self.cancel_btn = QPushButton("Cancel")
192 self.cancel_btn.clicked.connect(self.reject)
193 button_layout.addWidget(self.cancel_btn)
195 button_layout.addStretch()
196 main_layout.addLayout(button_layout)
198 def _setup_menu_bar(self):
199 """Setup menu bar with File, Edit, View menus."""
200 from PyQt6.QtWidgets import QMenuBar
202 self.menu_bar = QMenuBar(self)
204 # File menu
205 file_menu = self.menu_bar.addMenu("&File")
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)
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)
219 file_menu.addSeparator()
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)
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)
233 file_menu.addSeparator()
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)
241 # Edit menu
242 edit_menu = self.menu_bar.addMenu("&Edit")
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)
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)
256 edit_menu.addSeparator()
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)
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)
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)
274 edit_menu.addSeparator()
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)
282 # View menu
283 view_menu = self.menu_bar.addMenu("&View")
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)
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)
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)
307 # Current line highlighting
308 self.editor.setCaretLineVisible(True)
309 self.editor.setCaretLineBackgroundColor(Qt.GlobalColor.lightGray)
311 # Set font
312 font = QFont("Consolas", 10)
313 if not font.exactMatch():
314 font = QFont("Courier New", 10)
315 self.editor.setFont(font)
317 # Indentation
318 self.editor.setIndentationsUseTabs(False)
319 self.editor.setIndentationWidth(4)
320 self.editor.setTabWidth(4)
321 self.editor.setAutoIndent(True)
323 # Code folding
324 self.editor.setFolding(QsciScintilla.FoldStyle.BoxedTreeFoldStyle)
326 # Brace matching
327 self.editor.setBraceMatching(QsciScintilla.BraceMatch.SloppyBraceMatch)
329 # Selection
330 self.editor.setSelectionBackgroundColor(Qt.GlobalColor.blue)
332 # Enable UTF-8
333 self.editor.setUtf8(True)
335 def _apply_theme(self):
336 """Apply PyQt6ColorScheme theming to QScintilla editor."""
337 cs = self.color_scheme
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 """)
384 # Apply QScintilla-specific theming
385 self._apply_qscintilla_theme()
387 def _apply_qscintilla_theme(self):
388 """Apply color scheme to QScintilla editor and lexer."""
389 cs = self.color_scheme
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))
395 # Set margin colors
396 self.editor.setMarginsBackgroundColor(cs.to_qcolor(cs.frame_bg))
397 self.editor.setMarginsForegroundColor(cs.to_qcolor(cs.text_secondary))
399 # Set caret line color
400 self.editor.setCaretLineBackgroundColor(cs.to_qcolor(cs.selection_bg))
402 # Set selection colors
403 self.editor.setSelectionBackgroundColor(cs.to_qcolor(cs.selection_bg))
404 self.editor.setSelectionForegroundColor(cs.to_qcolor(cs.selection_text))
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)
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)
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)
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)
427 # Numbers
428 self.lexer.setColor(cs.to_qcolor(cs.python_number_color), QsciLexerPython.Number)
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)
434 # Operators
435 self.lexer.setColor(cs.to_qcolor(cs.python_operator_color), QsciLexerPython.Operator)
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)
441 # Decorators
442 self.lexer.setColor(cs.to_qcolor(cs.python_function_color), QsciLexerPython.Decorator)
444 # Set default background for lexer
445 self.lexer.setPaper(cs.to_qcolor(cs.panel_bg))
447 # Menu action handlers
448 def _new_file(self):
449 """Clear editor content."""
450 self.editor.clear()
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)}")
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)}")
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)
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)
494 def get_content(self) -> str:
495 """Get the edited content."""
496 return self.editor.text()
499class CodeEditorDialog(QDialog):
500 """
501 Fallback Qt native code editor dialog using QTextEdit.
503 Used when QScintilla is not available.
504 """
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)
512 # Setup UI
513 layout = QVBoxLayout(self)
515 # Text editor
516 self.text_edit = QTextEdit()
517 self.text_edit.setPlainText(initial_content)
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)
525 layout.addWidget(self.text_edit)
527 # Buttons
528 button_layout = QHBoxLayout()
530 save_btn = QPushButton("Save")
531 save_btn.clicked.connect(self.accept)
532 button_layout.addWidget(save_btn)
534 cancel_btn = QPushButton("Cancel")
535 cancel_btn.clicked.connect(self.reject)
536 button_layout.addWidget(cancel_btn)
538 button_layout.addStretch()
539 layout.addLayout(button_layout)
541 # Focus on text editor
542 self.text_edit.setFocus()
544 def get_content(self) -> str:
545 """Get the edited content."""
546 return self.text_edit.toPlainText()