""" 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, QLineEdit, QCheckBox, QMenu, QInputDialog, ) from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal, QTimer from PyQt6.QtGui import ( QColor, QPainter, QTextDocument, QTextFormat, QFont, QKeySequence, QShortcut, QFontMetrics, QTextCursor, ) from app.ui.syntax_highlighter import SQLHighlighter from app.ui.results_panel import ResultsPanel from app.ui.sql_completer import SqlCompleter 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, tab→spaces, and auto-complete.""" def __init__(self, parent=None): super().__init__(parent) self._line_area = LineNumberArea(self) self._completer: "SqlCompleter | None" = None # 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("#3d4155")) 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("#2a2d3e")) 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("#51576d")) 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 set_completer(self, completer: "SqlCompleter") -> None: self._completer = completer def _popup_visible(self) -> bool: return (self._completer is not None and self._completer._popup.isVisible()) def focusOutEvent(self, event): super().focusOutEvent(event) if self._completer: self._completer._popup.hide_popup() def keyPressEvent(self, event): # ── Popup navigation (highest priority) ────────────────────────────── if self._popup_visible(): key = event.key() if key == Qt.Key.Key_Escape: self._completer._popup.hide_popup() return if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Tab): self._completer._accept_completion( self._completer._popup.current_text() ) return if key == Qt.Key.Key_Up: self._completer._popup.move_selection(-1) return if key == Qt.Key.Key_Down: self._completer._popup.move_selection(1) return # ── Ctrl+Space → force-show completions ────────────────────────────── if (event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_Space): if self._completer: self._completer.trigger_completion(force=True) return # ── Tab → 4 spaces (only when popup is closed) ─────────────────────── if event.key() == Qt.Key.Key_Tab: self.textCursor().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) # ── Auto-trigger completions after every regular keystroke ──────────── if self._completer: self._completer.trigger_completion(force=False) 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() # ── Find / Replace bar ──────────────────────────────────────────────────────── class FindBar(QWidget): """Inline find/replace bar that slides in above the editor splitter.""" def __init__(self, editor: "CodeEditor", parent=None): super().__init__(parent) self._editor = editor self._replace_mode = False self._last_search = "" self._build_ui() def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(6, 4, 6, 4) root.setSpacing(3) # ── Find row ─────────────────────────────────────────────────────────── find_row = QHBoxLayout() find_row.setSpacing(4) self._find_input = QLineEdit() self._find_input.setPlaceholderText("Find…") self._find_input.setFixedHeight(26) self._find_input.textChanged.connect(self._on_find_text_changed) self._find_input.returnPressed.connect(self._find_next) self._prev_btn = QPushButton("▲") self._prev_btn.setFixedSize(26, 26) self._prev_btn.setToolTip("Find previous (Shift+Enter)") self._prev_btn.clicked.connect(self._find_prev) self._next_btn = QPushButton("▼") self._next_btn.setFixedSize(26, 26) self._next_btn.setToolTip("Find next (Enter)") self._next_btn.clicked.connect(self._find_next) self._case_cb = QCheckBox("Aa") self._case_cb.setToolTip("Match case") self._case_cb.toggled.connect(self._on_find_text_changed) self._word_cb = QCheckBox("\\b") self._word_cb.setToolTip("Whole word") self._word_cb.toggled.connect(self._on_find_text_changed) self._match_lbl = QLabel("") self._match_lbl.setObjectName("findMatchLbl") self._match_lbl.setMinimumWidth(60) close_btn = QPushButton("✕") close_btn.setFixedSize(26, 26) close_btn.setToolTip("Close (Esc)") close_btn.clicked.connect(self.hide_bar) find_row.addWidget(QLabel("Find:")) find_row.addWidget(self._find_input, 1) find_row.addWidget(self._prev_btn) find_row.addWidget(self._next_btn) find_row.addWidget(self._case_cb) find_row.addWidget(self._word_cb) find_row.addWidget(self._match_lbl) find_row.addStretch() find_row.addWidget(close_btn) root.addLayout(find_row) # ── Replace row (hidden in find-only mode) ───────────────────────────── self._replace_widget = QWidget() repl_row = QHBoxLayout(self._replace_widget) repl_row.setContentsMargins(0, 0, 0, 0) repl_row.setSpacing(4) self._repl_input = QLineEdit() self._repl_input.setPlaceholderText("Replace with…") self._repl_input.setFixedHeight(26) self._repl_input.returnPressed.connect(self._replace_one) repl_btn = QPushButton("Replace") repl_btn.setFixedHeight(26) repl_btn.clicked.connect(self._replace_one) repl_all_btn = QPushButton("Replace All") repl_all_btn.setFixedHeight(26) repl_all_btn.clicked.connect(self._replace_all) repl_row.addWidget(QLabel("Replace:")) repl_row.addWidget(self._repl_input, 1) repl_row.addWidget(repl_btn) repl_row.addWidget(repl_all_btn) repl_row.addStretch() root.addWidget(self._replace_widget) self._replace_widget.setVisible(False) # ── Public API ──────────────────────────────────────────────────────────── def show_find(self): self._replace_widget.setVisible(False) self._replace_mode = False self.setVisible(True) self._find_input.setFocus() self._find_input.selectAll() def show_replace(self): self._replace_widget.setVisible(True) self._replace_mode = True self.setVisible(True) self._find_input.setFocus() self._find_input.selectAll() def hide_bar(self): self.setVisible(False) self._editor.setFocus() # ── Find logic ──────────────────────────────────────────────────────────── def _flags(self, backward: bool = False) -> QTextDocument.FindFlag: flags = QTextDocument.FindFlag(0) if self._case_cb.isChecked(): flags |= QTextDocument.FindFlag.FindCaseSensitively if self._word_cb.isChecked(): flags |= QTextDocument.FindFlag.FindWholeWords if backward: flags |= QTextDocument.FindFlag.FindBackward return flags def _do_find(self, backward: bool = False) -> bool: text = self._find_input.text() if not text: return False found = self._editor.document().find(text, self._editor.textCursor(), self._flags(backward)) if found.isNull(): # Wrap around wrap = QTextCursor(self._editor.document()) if backward: wrap.movePosition(QTextCursor.MoveOperation.End) found = self._editor.document().find(text, wrap, self._flags(backward)) if not found.isNull(): self._editor.setTextCursor(found) self._set_no_match(False) return True self._set_no_match(True) return False def _find_next(self): self._do_find(backward=False) def _find_prev(self): self._do_find(backward=True) def _set_no_match(self, no_match: bool): color = "#e78284" if no_match else "" self._find_input.setStyleSheet( f"background: {color};" if no_match else "" ) self._match_lbl.setText("No match" if no_match else "") def _on_find_text_changed(self): self._set_no_match(False) text = self._find_input.text() if text and len(text) >= 1: self._do_find(backward=False) # ── Replace logic ───────────────────────────────────────────────────────── def _replace_one(self): if not self._do_find(backward=False): return tc = self._editor.textCursor() if tc.hasSelection(): tc.insertText(self._repl_input.text()) self._editor.setTextCursor(tc) self._do_find(backward=False) def _replace_all(self): text = self._find_input.text() repl = self._repl_input.text() if not text: return doc = self._editor.document() cursor = QTextCursor(doc) cursor.beginEditBlock() count = 0 found = doc.find(text, cursor, self._flags()) while not found.isNull(): found.insertText(repl) found = doc.find(text, found, self._flags()) count += 1 cursor.endEditBlock() self._match_lbl.setText(f"{count} replaced" if count else "No match") # ── Key events ──────────────────────────────────────────────────────────── def keyPressEvent(self, event): if event.key() == Qt.Key.Key_Escape: self.hide_bar() return if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if event.modifiers() & Qt.KeyboardModifier.ShiftModifier: self._find_prev() else: self._find_next() return super().keyPressEvent(event) # ── 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._filepath: str | 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) # ── Splitter: editor / results ────────────────────────────────────── self._splitter = QSplitter(Qt.Orientation.Vertical) self._splitter.setHandleWidth(3) self._editor = CodeEditor() SQLHighlighter(self._editor.document()) self._completer = SqlCompleter(self._editor, parent=self) self._editor.set_completer(self._completer) if self._driver: self._completer.set_context(self._driver, self._database) self._find_bar = FindBar(self._editor, parent=self) self._find_bar.setVisible(False) 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.addLayout(toolbar) root.addWidget(self._find_bar) root.addWidget(self._splitter, 1) # ── Shortcuts ────────────────────────────────────────────────────── QShortcut(QKeySequence("F5"), self, self._run) QShortcut(QKeySequence("Ctrl+Return"), self, self._run) QShortcut(QKeySequence("Ctrl+F"), self, self._find_bar.show_find) QShortcut(QKeySequence("Ctrl+H"), self, self._find_bar.show_replace) # ── 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 set_context(self, driver, database: str) -> None: """Update driver/database and refresh schema completions.""" self._driver = driver self._database = database self._db_label.setText(f"DB: {database}" if database else "") self._completer.set_context(driver, database) def _on_result(self, cols, rows, cnt, elapsed): self._results.show_data(cols, rows, cnt, elapsed) self._maybe_invalidate_schema(self._editor.toPlainText()) def _on_script_done(self, results: list): for cols, rows, cnt, msg in results: if cols: self._results.show_data(cols, rows, cnt, 0) else: self._results.show_message(msg) self._maybe_invalidate_schema(self._editor.toPlainText()) def _on_error(self, msg: str): self._results.show_error(msg) def _maybe_invalidate_schema(self, sql: str) -> None: if any(sql.lstrip().lower().startswith(kw) for kw in ("create", "drop", "alter", "rename")): self._completer.invalidate() # ── 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) # Right-click context menu on query tab bar self._tabs.tabBar().setContextMenuPolicy( Qt.ContextMenuPolicy.CustomContextMenu ) self._tabs.tabBar().customContextMenuRequested.connect( self._tab_context_menu ) 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 _tab_context_menu(self, pos) -> None: idx = self._tabs.tabBar().tabAt(pos) if idx < 0: return menu = QMenu(self) rename_act = menu.addAction("Rename…") dup_act = menu.addAction("Duplicate") menu.addSeparator() close_act = menu.addAction("Close") close_others = menu.addAction("Close Others") close_act.setEnabled(self._tabs.count() > 1) close_others.setEnabled(self._tabs.count() > 1) act = menu.exec(self._tabs.tabBar().mapToGlobal(pos)) if act == rename_act: text, ok = QInputDialog.getText( self, "Rename Tab", "Tab name:", text=self._tabs.tabText(idx), ) if ok and text.strip(): self._tabs.setTabText(idx, text.strip()) elif act == dup_act: tab = self._tabs.widget(idx) if isinstance(tab, EditorTab): new = self.new_tab( tab._driver, tab._database, sql=tab.get_sql(), title=self._tabs.tabText(idx) + " (copy)", ) new._filepath = tab._filepath elif act == close_act: self._close_tab(idx) elif act == close_others: for i in range(self._tabs.count() - 1, -1, -1): if i != idx: self._close_tab(i) 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)