Files
DBClient/app/ui/syntax_highlighter.py
T
nngoandClaude Sonnet 4.6 06b951daf7 feat: dark/light theme toggle (Catppuccin Frappé ↔ Latte)
- app/config/theme.py: palette definitions for both themes + apply_qss()
  helper used by both main.py (startup) and MainWindow (live toggle)
- resources/style_light.qss: complete Catppuccin Latte QSS
- View menu: ☀️/🌙 Switch Theme action (Ctrl+Shift+T) toggles live
- Preferences → Appearance: Color theme dropdown persists choice
- SQLHighlighter: _build_rules() reads get_palette(); update_theme()
  rebuilds rules and rehighlights all open editors on switch
- SqlCompleter: popup stylesheet reads get_palette() via
  _apply_popup_style(); update_theme() called on switch
- EditableTableModel: dirty/delete cell colors read get_palette() live
- ResultTableModel: NULL_COLOR property reads get_palette() live
- TableViewer: apply_settings() refreshes frozen-view border + repaints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 09:11:05 -04:00

142 lines
5.9 KiB
Python

"""
SQL syntax highlighter for QPlainTextEdit.
Highlights keywords, types, functions, strings, comments, and numbers.
Colors follow the active Catppuccin theme (dark = Frappé, light = Latte).
"""
import re
from PyQt6.QtCore import QRegularExpression, Qt
from PyQt6.QtGui import (
QSyntaxHighlighter, QTextCharFormat, QColor, QFont
)
from app.config.theme import get_palette
def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharFormat:
f = QTextCharFormat()
f.setForeground(QColor(color))
if bold: f.setFontWeight(QFont.Weight.Bold)
if italic: f.setFontItalic(True)
return f
# ── Keyword lists (shared with completer) ─────────────────────────────────────
_KEYWORDS = [
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
"FULL", "CROSS", "ON", "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
"IS", "NULL", "EXISTS", "CASE", "WHEN", "THEN", "ELSE", "END",
"INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE",
"CREATE", "TABLE", "VIEW", "INDEX", "DATABASE", "SCHEMA", "SEQUENCE",
"DROP", "ALTER", "ADD", "COLUMN", "PRIMARY", "KEY", "FOREIGN",
"REFERENCES", "UNIQUE", "DEFAULT", "CONSTRAINT", "CHECK",
"IF", "EXISTS", "NOT", "TRUNCATE", "RENAME", "MODIFY", "CHANGE",
"GRANT", "REVOKE", "COMMIT", "ROLLBACK", "SAVEPOINT", "BEGIN",
"TRANSACTION", "START", "END", "LOCK", "UNLOCK", "EXPLAIN",
"ANALYZE", "VACUUM", "SHOW", "DESCRIBE", "DESC", "ASC",
"LIMIT", "OFFSET", "ORDER", "BY", "GROUP", "HAVING", "DISTINCT",
"UNION", "ALL", "INTERSECT", "EXCEPT", "PARTITION", "OVER",
"WITH", "RECURSIVE", "USE", "CALL", "EXEC", "EXECUTE",
"PROCEDURE", "FUNCTION", "TRIGGER", "EVENT", "REPLACE",
"TRUE", "FALSE", "RETURNING",
]
_TYPES = [
"INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT", "MEDIUMINT",
"FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL", "MONEY",
"VARCHAR", "CHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT",
"BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", "BINARY", "VARBINARY",
"DATE", "DATETIME", "TIMESTAMP", "TIME", "YEAR",
"BOOLEAN", "BOOL", "BIT", "ENUM", "SET",
"JSON", "UUID", "SERIAL", "BYTEA", "NVARCHAR", "NCHAR",
"IMAGE", "XML", "CURSOR", "ROWVERSION", "UNIQUEIDENTIFIER",
]
_FUNCTIONS = [
"COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "IFNULL", "NULLIF",
"NOW", "CURDATE", "CURTIME", "DATE", "YEAR", "MONTH", "DAY",
"CONCAT", "SUBSTRING", "LENGTH", "TRIM", "UPPER", "LOWER",
"REPLACE", "ROUND", "FLOOR", "CEIL", "ABS", "MOD", "POWER",
"CAST", "CONVERT", "ISNULL", "ISNUMERIC",
"ROW_NUMBER", "RANK", "DENSE_RANK", "LAG", "LEAD", "NTILE",
"FIRST_VALUE", "LAST_VALUE", "NTH_VALUE",
"STRING_AGG", "GROUP_CONCAT", "ARRAY_AGG",
"TO_CHAR", "TO_DATE", "TO_NUMBER", "EXTRACT",
"DATEDIFF", "DATEADD", "DATE_FORMAT", "DATE_TRUNC",
"IF", "IIF", "DECODE", "GREATEST", "LEAST",
]
class SQLHighlighter(QSyntaxHighlighter):
"""Applies syntax colouring to a SQL document."""
def __init__(self, document):
super().__init__(document)
self._rules: list[tuple] = []
self._build_rules()
def _build_rules(self):
p = get_palette()
def kw_pattern(words: list[str]) -> str:
return r"\b(?:" + "|".join(words) + r")\b"
ci = QRegularExpression.PatternOption.CaseInsensitiveOption
self._rules = [
(QRegularExpression(kw_pattern(_KEYWORDS), ci),
_fmt(p["syn_keyword"], bold=True)),
(QRegularExpression(kw_pattern(_TYPES), ci),
_fmt(p["syn_type"])),
(QRegularExpression(kw_pattern(_FUNCTIONS), ci),
_fmt(p["syn_function"])),
(QRegularExpression(r"\b\d+(\.\d+)?\b"),
_fmt(p["syn_number"])),
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"),
_fmt(p["syn_string"])),
(QRegularExpression(r'"[^"]*"'),
_fmt(p["syn_ident"])),
(QRegularExpression(r"`[^`]*`"),
_fmt(p["syn_ident"])),
(QRegularExpression(
r"\bSELECT\s+\*|\*(?=\s*FROM)", ci),
_fmt(p["syn_operator"], bold=True)),
(QRegularExpression(r"[=<>!%&|^~+\-*/]"),
_fmt(p["syn_operator"])),
(QRegularExpression(r"--[^\n]*"),
_fmt(p["syn_comment"], italic=True)),
(QRegularExpression(r"#[^\n]*"),
_fmt(p["syn_comment"], italic=True)),
]
self._comment_fmt = _fmt(p["syn_comment"], italic=True)
self._block_comment_start = QRegularExpression(r"/\*")
self._block_comment_end = QRegularExpression(r"\*/")
def update_theme(self):
"""Rebuild color rules for the current theme and re-highlight."""
self._build_rules()
self.rehighlight()
def highlightBlock(self, text: str) -> None:
for pattern, fmt in self._rules:
it = pattern.globalMatch(text)
while it.hasNext():
m = it.next()
self.setFormat(m.capturedStart(), m.capturedLength(), fmt)
self.setCurrentBlockState(0)
start_idx = 0
if self.previousBlockState() != 1:
m = self._block_comment_start.match(text)
start_idx = m.capturedStart() if m.hasMatch() else -1
while start_idx >= 0:
end_m = self._block_comment_end.match(text, start_idx)
if end_m.hasMatch():
end_idx = end_m.capturedStart() + end_m.capturedLength()
self.setFormat(start_idx, end_idx - start_idx, self._comment_fmt)
m = self._block_comment_start.match(text, end_idx)
start_idx = m.capturedStart() if m.hasMatch() else -1
else:
self.setCurrentBlockState(1)
self.setFormat(start_idx, len(text) - start_idx, self._comment_fmt)
break