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>
This commit is contained in:
2026-05-22 09:11:05 -04:00
co-authored by Claude Sonnet 4.6
parent 6cff9557b3
commit 06b951daf7
11 changed files with 857 additions and 76 deletions
+121
View File
@@ -0,0 +1,121 @@
"""
Theme palettes — Catppuccin Frappé (dark) and Catppuccin Latte (light).
Usage:
from app.config.theme import get_palette, apply_qss, is_dark
"""
from pathlib import Path
# ── Catppuccin Frappé ─────────────────────────────────────────────────────────
DARK = {
"base": "#303446",
"mantle": "#292c3c",
"crust": "#232634",
"surface0": "#414559",
"surface1": "#51576d",
"surface2": "#626880",
"overlay": "#737994",
"subtext": "#a5adce",
"text": "#c6d0f5",
"blue": "#8caaee",
"lavender": "#babbf1",
"green": "#a6d189",
"teal": "#81c8be",
"sky": "#99d1db",
"red": "#e78284",
"peach": "#ef9f76",
"yellow": "#e5c890",
"mauve": "#ca9ee6",
# derived UI tokens
"gutter_bg": "#2a2d3e",
"dirty_bg": "#3a2f25",
"dirty_fg": "#ef9f76",
"delete_bg": "#3a2530",
"delete_fg": "#e78284",
# syntax
"syn_keyword": "#8caaee",
"syn_type": "#ef9f76",
"syn_function": "#a6d189",
"syn_string": "#a6d189",
"syn_number": "#ef9f76",
"syn_comment": "#737994",
"syn_operator": "#ca9ee6",
"syn_ident": "#99d1db",
# completer popup
"popup_bg": "#292c3c",
"popup_border": "#51576d",
"popup_selected": "#414559",
"popup_hover": "#363a4f",
}
# ── Catppuccin Latte ──────────────────────────────────────────────────────────
LIGHT = {
"base": "#eff1f5",
"mantle": "#e6e9ef",
"crust": "#dce0e8",
"surface0": "#ccd0da",
"surface1": "#bcc0cc",
"surface2": "#acb0be",
"overlay": "#9ca0b0",
"subtext": "#6c6f85",
"text": "#4c4f69",
"blue": "#1e66f5",
"lavender": "#7287fd",
"green": "#40a02b",
"teal": "#179299",
"sky": "#04a5e5",
"red": "#d20f39",
"peach": "#fe640b",
"yellow": "#df8e1d",
"mauve": "#8839ef",
# derived UI tokens
"gutter_bg": "#dce0e8",
"dirty_bg": "#fff3e6",
"dirty_fg": "#fe640b",
"delete_bg": "#ffe8ed",
"delete_fg": "#d20f39",
# syntax
"syn_keyword": "#1e66f5",
"syn_type": "#fe640b",
"syn_function": "#40a02b",
"syn_string": "#40a02b",
"syn_number": "#fe640b",
"syn_comment": "#9ca0b0",
"syn_operator": "#8839ef",
"syn_ident": "#04a5e5",
# completer popup
"popup_bg": "#e6e9ef",
"popup_border": "#bcc0cc",
"popup_selected": "#ccd0da",
"popup_hover": "#d8dce8",
}
_QSS_DIR = Path(__file__).parent.parent.parent / "resources"
def get_palette() -> dict:
"""Return the color dict for the currently active theme."""
from app.config.settings import get_settings
name = get_settings().get("theme", "dark")
return DARK if name == "dark" else LIGHT
def is_dark() -> bool:
from app.config.settings import get_settings
return get_settings().get("theme", "dark") == "dark"
def qss_path(theme: str) -> Path:
name = "style" if theme == "dark" else "style_light"
return _QSS_DIR / f"{name}.qss"
def apply_qss(app, theme: str = None) -> None:
"""Load and apply the QSS for *theme* (defaults to current setting)."""
from app.config.settings import get_settings
if theme is None:
theme = get_settings().get("theme", "dark")
path = qss_path(theme)
if path.exists():
app.setStyleSheet(path.read_text(encoding="utf-8"))
+28 -1
View File
@@ -17,6 +17,7 @@ from PyQt6.QtWidgets import (
)
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
from PyQt6.QtGui import QAction, QColor, QKeySequence
from app.config.theme import apply_qss, is_dark
from app.ui.schema_browser import SchemaBrowser
from app.ui.sql_editor import SQLEditorWidget
@@ -208,6 +209,12 @@ class MainWindow(QMainWindow):
view_menu.addAction(self._act("Toggle Query History", self._toggle_history, "Ctrl+Shift+H"))
view_menu.addAction(self._act("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
view_menu.addAction(self._act("Close Tab", self._close_current_tab, "Ctrl+W"))
view_menu.addSeparator()
self._theme_action = QAction("", self)
self._theme_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
self._theme_action.triggered.connect(self._toggle_theme)
self._update_theme_action_label()
view_menu.addAction(self._theme_action)
# ── Tools ─────────────────────────────────────────────────────────────
tools_menu = mb.addMenu("&Tools")
@@ -662,9 +669,29 @@ class MainWindow(QMainWindow):
def _open_preferences(self):
dlg = PreferencesDialog(parent=self)
dlg.settings_applied.connect(self._apply_settings_to_open_tabs)
dlg.settings_applied.connect(self._on_settings_applied)
dlg.exec()
def _on_settings_applied(self):
apply_qss(QApplication.instance())
self._update_theme_action_label()
self._apply_settings_to_open_tabs()
def _toggle_theme(self):
from app.config.settings import get_settings
new_theme = "light" if is_dark() else "dark"
get_settings().set("theme", new_theme)
get_settings().save()
apply_qss(QApplication.instance(), new_theme)
self._update_theme_action_label()
self._apply_settings_to_open_tabs()
def _update_theme_action_label(self):
if is_dark():
self._theme_action.setText("☀️ Switch to Light Theme")
else:
self._theme_action.setText("🌙 Switch to Dark Theme")
def _apply_settings_to_open_tabs(self):
"""Push updated settings to all currently open workspace tabs."""
for i in range(self._workspace.count()):
+5 -1
View File
@@ -7,11 +7,15 @@ from PyQt6.QtCore import (
)
from PyQt6.QtGui import QColor, QFont
from app.config.theme import get_palette
class ResultTableModel(QAbstractTableModel):
"""Immutable result-set model — replaces data via set_data()."""
NULL_COLOR = QColor("#737994") # muted grey for NULL
@property
def NULL_COLOR(self):
return QColor(get_palette()["overlay"])
NUM_ALIGN = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
TEXT_ALIGN = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
+20 -1
View File
@@ -10,6 +10,8 @@ from app.config.settings import get_settings
_FONT_FAMILIES = ["Consolas", "JetBrains Mono", "Fira Code", "Cascadia Code", "Courier New"]
_PAGE_SIZES = [50, 100, 500, 1000, 0] # 0 → All
_PAGE_LABELS = ["50", "100", "500", "1 000", "All"]
_THEMES = [("dark", "🌙 Dark (Catppuccin Frappé)"),
("light", "☀️ Light (Catppuccin Latte)")]
class PreferencesDialog(QDialog):
@@ -64,6 +66,18 @@ class PreferencesDialog(QDialog):
root.addWidget(results_box)
# ── Appearance ────────────────────────────────────────────────────────
appear_box = QGroupBox("Appearance")
af = QFormLayout(appear_box)
af.setSpacing(8)
self._theme = QComboBox()
for _, label in _THEMES:
self._theme.addItem(label)
af.addRow("Color theme:", self._theme)
root.addWidget(appear_box)
# ── General ───────────────────────────────────────────────────────────
general_box = QGroupBox("General")
gf = QFormLayout(general_box)
@@ -82,7 +96,7 @@ class PreferencesDialog(QDialog):
root.addStretch()
# ── Buttons ───────────────────────────────────────────────────────────
note = QLabel("Font changes take effect immediately in open editors.")
note = QLabel("Theme and font changes take effect immediately.")
note.setObjectName("statusLabel")
root.addWidget(note)
@@ -116,6 +130,10 @@ class PreferencesDialog(QDialog):
self._max_history.setValue(s.get("max_history", 500))
self._auto_commit.setChecked(s.get("auto_commit", True))
theme_name = s.get("theme", "dark")
ti = next((i for i, (k, _) in enumerate(_THEMES) if k == theme_name), 0)
self._theme.setCurrentIndex(ti)
def _apply(self):
s = get_settings()
s.set("font_family", self._font_family.currentText())
@@ -125,6 +143,7 @@ class PreferencesDialog(QDialog):
s.set("query_timeout", self._timeout.value())
s.set("max_history", self._max_history.value())
s.set("auto_commit", self._auto_commit.isChecked())
s.set("theme", _THEMES[self._theme.currentIndex()][0])
s.save()
self.settings_applied.emit()
+23 -14
View File
@@ -12,6 +12,7 @@ from PyQt6.QtWidgets import QApplication, QListWidget, QListWidgetItem
from app.ui.syntax_highlighter import _KEYWORDS, _TYPES, _FUNCTIONS
from app.utils.worker import SchemaWorker
from app.config.theme import get_palette
# Combined, deduplicated keyword list used as the static completion pool.
SQL_KEYWORDS: list[str] = list(dict.fromkeys(_KEYWORDS + _TYPES + _FUNCTIONS))
@@ -51,24 +52,28 @@ class _CompletionPopup(QListWidget):
self.setFont(font)
self._row_h = QFontMetrics(font).height() + 6
self.setStyleSheet("""
QListWidget {
background: #292c3c;
color: #c6d0f5;
border: 1px solid #51576d;
self._apply_popup_style()
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
def _apply_popup_style(self):
p = get_palette()
self.setStyleSheet(f"""
QListWidget {{
background: {p['popup_bg']};
color: {p['text']};
border: 1px solid {p['popup_border']};
border-radius: 4px;
padding: 2px;
outline: none;
}
QListWidget::item { padding: 2px 8px; }
QListWidget::item:selected {
background: #414559;
color: #c6d0f5;
}
QListWidget::item:hover { background: #363a4f; }
}}
QListWidget::item {{ padding: 2px 8px; }}
QListWidget::item:selected {{
background: {p['popup_selected']};
color: {p['text']};
}}
QListWidget::item:hover {{ background: {p['popup_hover']}; }}
""")
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.itemClicked.connect(
lambda item: self._editor._completer._accept_completion(item.text())
@@ -142,6 +147,10 @@ class SqlCompleter(QObject):
# ── Public API ────────────────────────────────────────────────────────────
def update_theme(self) -> None:
"""Reapply popup colors after a theme change."""
self._popup._apply_popup_style()
def set_context(self, driver, database: str) -> None:
"""Update the active driver/database and refresh schema cache."""
self._driver = driver
+3 -1
View File
@@ -471,7 +471,7 @@ class EditorTab(QWidget):
self._splitter.setHandleWidth(3)
self._editor = CodeEditor()
SQLHighlighter(self._editor.document())
self._highlighter = SQLHighlighter(self._editor.document())
self._completer = SqlCompleter(self._editor, parent=self)
self._editor.set_completer(self._completer)
if self._driver:
@@ -603,6 +603,8 @@ class EditorTab(QWidget):
def apply_settings(self) -> None:
self._editor.apply_settings()
self._highlighter.update_theme()
self._completer.update_theme()
# ── Tabbed SQL editor container ───────────────────────────────────────────────
+40 -47
View File
@@ -1,6 +1,7 @@
"""
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
@@ -8,6 +9,8 @@ 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()
@@ -17,17 +20,7 @@ def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharForma
return f
# ── Token categories (Catppuccin Frappé palette) ─────────────────────────────
_KEYWORD_FMT = _fmt("#8caaee", bold=True) # blue — DDL/DML/control
_TYPE_FMT = _fmt("#ef9f76") # peach — data types
_FUNCTION_FMT = _fmt("#a6d189") # green — functions
_STRING_FMT = _fmt("#a6d189") # green — string literals
_NUMBER_FMT = _fmt("#ef9f76") # orange — numeric literals
_COMMENT_FMT = _fmt("#737994", italic=True) # grey — comments
_OPERATOR_FMT = _fmt("#ca9ee6") # mauve — operators/special
_STAR_FMT = _fmt("#ca9ee6", bold=True) # mauve — * wildcard
# ── Keyword lists ─────────────────────────────────────────────────────────────
# ── Keyword lists (shared with completer) ─────────────────────────────────────
_KEYWORDS = [
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
"FULL", "CROSS", "ON", "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
@@ -79,56 +72,56 @@ class SQLHighlighter(QSyntaxHighlighter):
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 = [
# Keywords (case-insensitive handled via flag)
(QRegularExpression(kw_pattern(_KEYWORDS),
QRegularExpression.PatternOption.CaseInsensitiveOption),
_KEYWORD_FMT),
# Data types
(QRegularExpression(kw_pattern(_TYPES),
QRegularExpression.PatternOption.CaseInsensitiveOption),
_TYPE_FMT),
# Functions
(QRegularExpression(kw_pattern(_FUNCTIONS),
QRegularExpression.PatternOption.CaseInsensitiveOption),
_FUNCTION_FMT),
# Numbers
(QRegularExpression(r"\b\d+(\.\d+)?\b"), _NUMBER_FMT),
# Single-quoted strings
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"), _STRING_FMT),
# Double-quoted identifiers
(QRegularExpression(r'"[^"]*"'), _fmt("#99d1db")),
# Backtick identifiers (MySQL)
(QRegularExpression(r"`[^`]*`"), _fmt("#99d1db")),
# * wildcard
(QRegularExpression(r"\bSELECT\s+\*|\*(?=\s*FROM)",
QRegularExpression.PatternOption.CaseInsensitiveOption),
_STAR_FMT),
# Operators
(QRegularExpression(r"[=<>!%&|^~+\-*/]"), _OPERATOR_FMT),
# Single-line comment --
(QRegularExpression(r"--[^\n]*"), _COMMENT_FMT),
# Single-line comment #
(QRegularExpression(r"#[^\n]*"), _COMMENT_FMT),
(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)),
]
# Multi-line block comment /* ... */
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:
# Single-line rules
for pattern, fmt in self._rules:
it = pattern.globalMatch(text)
while it.hasNext():
m = it.next()
self.setFormat(m.capturedStart(), m.capturedLength(), fmt)
# Multi-line block comments
self.setCurrentBlockState(0)
start_idx = 0
if self.previousBlockState() != 1:
@@ -139,10 +132,10 @@ class SQLHighlighter(QSyntaxHighlighter):
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, _COMMENT_FMT)
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, _COMMENT_FMT)
self.setFormat(start_idx, len(text) - start_idx, self._comment_fmt)
break
+15 -6
View File
@@ -22,6 +22,7 @@ from PyQt6.QtGui import QColor, QBrush, QFont, QKeySequence, QShortcut
from app.utils.worker import TableDataWorker
from app.config.settings import get_settings
from app.config.theme import get_palette
# ─────────────────────────────────────────────────────────────────────────────
@@ -148,18 +149,20 @@ class EditableTableModel(QAbstractTableModel):
return "" if val is None else str(val)
if role == Qt.ItemDataRole.ForegroundRole:
p = get_palette()
if self._is_deleted_row(r):
return QBrush(QColor("#e78284")) # red — pending delete
return QBrush(QColor(p["delete_fg"]))
if (r, c) in self._dirty:
return QBrush(QColor("#ef9f76")) # orange — edited
return QBrush(QColor(p["dirty_fg"]))
if val is None:
return QBrush(QColor("#737994")) # grey — NULL
return QBrush(QColor(p["overlay"]))
if role == Qt.ItemDataRole.BackgroundRole:
p = get_palette()
if self._is_deleted_row(r):
return QBrush(QColor("#3a2530"))
return QBrush(QColor(p["delete_bg"]))
if (r, c) in self._dirty:
return QBrush(QColor("#3a2f25"))
return QBrush(QColor(p["dirty_bg"]))
return None
@@ -444,7 +447,7 @@ class TableViewer(QWidget):
self._frozen_view.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Fixed)
self._frozen_view.setStyleSheet(
"QTableView { border: none; border-right: 2px solid #8caaee; }")
f"QTableView {{ border: none; border-right: 2px solid {get_palette()['blue']}; }}")
self._frozen_view.hide()
# Sync vertical scroll between the two views
@@ -887,6 +890,12 @@ class TableViewer(QWidget):
)
QApplication.clipboard().setText("\n".join(statements))
def apply_settings(self):
"""Called by MainWindow when preferences (including theme) change."""
self._frozen_view.setStyleSheet(
f"QTableView {{ border: none; border-right: 2px solid {get_palette()['blue']}; }}")
self._model.layoutChanged.emit() # repaint dirty/delete cells
def _filter_by_cell(self, index):
if not index.isValid():
return