""" Multi-tab SQL editor with syntax highlighting, line numbers, and run controls. """ import os from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QTextEdit, QTabWidget, QPushButton, QLabel, QSplitter, QTabBar, QSizePolicy, QFileDialog, QMessageBox, QToolButton, QComboBox, ) from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer from PyQt6.QtGui import ( QColor, QPainter, QTextFormat, QFont, QKeySequence, QShortcut, QFontMetrics, QTextCursor, ) from app.ui.syntax_highlighter import SQLHighlighter from app.ui.results_panel import ResultsPanel from app.utils.worker import QueryWorker # ── Line-number gutter ──────────────────────────────────────────────────────── class LineNumberArea(QWidget): def __init__(self, editor): super().__init__(editor) self._editor = editor def sizeHint(self) -> QSize: return QSize(self._editor.line_number_area_width(), 0) def paintEvent(self, event): self._editor.line_number_area_paint_event(event) class CodeEditor(QPlainTextEdit): """QPlainTextEdit with line numbers, current-line highlight, and tab→spaces.""" def __init__(self, parent=None): super().__init__(parent) self._line_area = LineNumberArea(self) # Font font = QFont("Consolas", 13) font.setFixedPitch(True) self.setFont(font) self.setTabStopDistance(QFontMetrics(font).horizontalAdvance(" ") * 4) # Connect signals self.blockCountChanged.connect(self._update_line_area_width) self.updateRequest.connect(self._update_line_area) self.cursorPositionChanged.connect(self._highlight_current_line) self._update_line_area_width(0) self._highlight_current_line() def line_number_area_width(self) -> int: digits = max(3, len(str(self.blockCount()))) return 12 + self.fontMetrics().horizontalAdvance("9") * digits def _update_line_area_width(self, _): self.setViewportMargins(self.line_number_area_width(), 0, 0, 0) def _update_line_area(self, rect, dy): if dy: self._line_area.scroll(0, dy) else: self._line_area.update(0, rect.y(), self._line_area.width(), rect.height()) if rect.contains(self.viewport().rect()): self._update_line_area_width(0) def resizeEvent(self, event): super().resizeEvent(event) cr = self.contentsRect() self._line_area.setGeometry( QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height()) ) def _highlight_current_line(self): extra = [] if not self.isReadOnly(): sel = QTextEdit.ExtraSelection() sel.format.setBackground(QColor("#2a2a3c")) sel.format.setProperty(QTextFormat.Property.FullWidthSelection, True) sel.cursor = self.textCursor() sel.cursor.clearSelection() extra.append(sel) self.setExtraSelections(extra) def line_number_area_paint_event(self, event): painter = QPainter(self._line_area) painter.fillRect(event.rect(), QColor("#1a1a2e")) block = self.firstVisibleBlock() number = block.blockNumber() top = round(self.blockBoundingGeometry(block).translated( self.contentOffset()).top()) bottom = top + round(self.blockBoundingRect(block).height()) while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): painter.setPen(QColor("#45475a")) painter.drawText( 0, top, self._line_area.width() - 6, self.fontMetrics().height(), Qt.AlignmentFlag.AlignRight, str(number + 1) ) block = block.next() top = bottom bottom = top + round(self.blockBoundingRect(block).height()) number += 1 def keyPressEvent(self, event): # Tab → 4 spaces if event.key() == Qt.Key.Key_Tab: cursor = self.textCursor() cursor.insertText(" ") return # Ctrl+/ → toggle comment if event.modifiers() == Qt.KeyboardModifier.ControlModifier \ and event.key() == Qt.Key.Key_Slash: self._toggle_comment() return super().keyPressEvent(event) def _toggle_comment(self): cursor = self.textCursor() start = cursor.selectionStart() end = cursor.selectionEnd() cursor.setPosition(start) cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock) cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor) text = cursor.selectedText() lines = text.split("\u2029") # Qt paragraph separator if all(l.lstrip().startswith("--") for l in lines if l.strip()): new = [l.replace("--", "", 1) if l.lstrip().startswith("--") else l for l in lines] else: new = ["--" + l for l in lines] cursor.insertText("\u2029".join(new)) def selected_or_all(self) -> str: cursor = self.textCursor() text = cursor.selectedText().replace("\u2029", "\n") return text if text.strip() else self.toPlainText() # ── Single editor tab (editor + results splitter) ───────────────────────────── class EditorTab(QWidget): status_message = pyqtSignal(str) def __init__(self, driver, database: str = "", parent=None): super().__init__(parent) self._driver = driver self._database = database self._worker: QueryWorker | None = None self._build_ui() def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) # ── Editor toolbar ───────────────────────────────────────────────────── toolbar = QHBoxLayout() toolbar.setContentsMargins(6, 4, 6, 4) toolbar.setSpacing(4) self._run_btn = QPushButton("▶ Run F5") self._run_btn.setObjectName("runBtn") self._run_btn.clicked.connect(self._run) self._stop_btn = QPushButton("⏹ Stop") self._stop_btn.setObjectName("stopBtn") self._stop_btn.setEnabled(False) self._stop_btn.clicked.connect(self._stop) self._explain_btn = QPushButton("🔎 Explain") self._explain_btn.clicked.connect(self._explain) self._export_btn = QPushButton("📤 Export") self._export_btn.clicked.connect(self._export) self._db_label = QLabel(f"DB: {self._database}" if self._database else "") self._db_label.setObjectName("dbLabel") toolbar.addWidget(self._run_btn) toolbar.addWidget(self._stop_btn) toolbar.addWidget(self._explain_btn) toolbar.addWidget(self._export_btn) toolbar.addStretch() toolbar.addWidget(self._db_label) root.addLayout(toolbar) # ── Splitter: editor / results ────────────────────────────────────── self._splitter = QSplitter(Qt.Orientation.Vertical) self._splitter.setHandleWidth(3) self._editor = CodeEditor() SQLHighlighter(self._editor.document()) self._results = ResultsPanel() self._results.status_message.connect(self.status_message) self._splitter.addWidget(self._editor) self._splitter.addWidget(self._results) self._splitter.setSizes([400, 250]) root.addWidget(self._splitter, 1) # ── Shortcuts ────────────────────────────────────────────────────── QShortcut(QKeySequence("F5"), self, self._run) QShortcut(QKeySequence("Ctrl+Return"), self, self._run) # ── Run logic ───────────────────────────────────────────────────────────── def _run(self): sql = self._editor.selected_or_all().strip() if not sql: return self._results.show_loading() self._run_btn.setEnabled(False) self._stop_btn.setEnabled(True) is_script = ";" in sql[:-1] # multiple statements self._worker = QueryWorker(self._driver, sql, is_script=is_script) self._worker.finished.connect(self._on_result) self._worker.script_done.connect(self._on_script_done) self._worker.error.connect(self._on_error) self._worker.finished.connect(lambda *_: self._reset_buttons()) self._worker.script_done.connect(lambda *_: self._reset_buttons()) self._worker.error.connect(lambda *_: self._reset_buttons()) self._worker.start() def _stop(self): if self._worker and self._worker.isRunning(): self._worker.terminate() self._reset_buttons() def _reset_buttons(self): self._run_btn.setEnabled(True) self._stop_btn.setEnabled(False) def _explain(self): sql = self._editor.selected_or_all().strip() if not sql: return # Prefer opening a full ExplainPanel in the main window workspace from app.main_window import MainWindow win = self.window() if isinstance(win, MainWindow): win.open_explain_tab(self._driver, self._database, sql) else: # Fallback: show raw EXPLAIN in the inline results panel try: cols, rows = self._driver.explain_query(sql) self._results.show_data(cols, rows, len(rows), 0) except Exception as e: self._results.show_error(str(e)) def _export(self): self._results.export_dialog() def _on_result(self, cols, rows, cnt, elapsed): self._results.show_data(cols, rows, cnt, elapsed) def _on_script_done(self, results: list): # Show the last SELECT result; messages for DML for cols, rows, cnt, msg in results: if cols: self._results.show_data(cols, rows, cnt, 0) else: self._results.show_message(msg) def _on_error(self, msg: str): self._results.show_error(msg) # ── Public ───────────────────────────────────────────────────────────────── def set_sql(self, sql: str): self._editor.setPlainText(sql) def get_sql(self) -> str: return self._editor.toPlainText() # ── Tabbed SQL editor container ─────────────────────────────────────────────── class SQLEditorWidget(QWidget): status_message = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self._build_ui() def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) self._tabs = QTabWidget() self._tabs.setTabsClosable(True) self._tabs.setMovable(True) self._tabs.tabCloseRequested.connect(self._close_tab) # New tab button new_btn = QToolButton() new_btn.setText("+") new_btn.setToolTip("New SQL Tab") new_btn.clicked.connect(lambda: self.new_tab()) self._tabs.setCornerWidget(new_btn, Qt.Corner.TopRightCorner) root.addWidget(self._tabs) def new_tab(self, driver=None, database: str = "", sql: str = "", title: str = None) -> EditorTab: tab = EditorTab(driver, database) tab.status_message.connect(self.status_message) if sql: tab.set_sql(sql) label = title or (f"Query — {database}" if database else "Query") idx = self._tabs.addTab(tab, label) self._tabs.setCurrentIndex(idx) return tab def _close_tab(self, idx: int): if self._tabs.count() > 1: self._tabs.removeTab(idx) def current_tab(self) -> EditorTab | None: w = self._tabs.currentWidget() return w if isinstance(w, EditorTab) else None def open_sql_for(self, driver, database: str, sql: str = ""): tab = self.new_tab(driver, database, sql, title=f"SQL — {database}") if sql: tab.set_sql(sql)