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
+1 -1
View File
@@ -144,6 +144,7 @@ All four drivers have a `_where(where: dict) → (clause_str, params)` static he
- Connection ping indicator: 30-second `_PingWorker` (QThread) pings each active connection via `test_connection()`; status dot (● green / ● red) shown in status bar right side with per-connection tooltip - Connection ping indicator: 30-second `_PingWorker` (QThread) pings each active connection via `test_connection()`; status dot (● green / ● red) shown in status bar right side with per-connection tooltip
- Frozen first column in `TableViewer`: 📌 toggle button in toolbar; dual-view overlay (`_frozen_view` child of `_table_view`) with synced vertical scroll and row heights; event-filter updates geometry on resize - Frozen first column in `TableViewer`: 📌 toggle button in toolbar; dual-view overlay (`_frozen_view` child of `_table_view`) with synced vertical scroll and row heights; event-filter updates geometry on resize
- Keyboard navigation in dialogs: OK set as default button (Enter submits) in `RowDialog` and `ConnectionDialog`; focus jumps to first input field on open; Enter in password field submits `ConnectionDialog` - Keyboard navigation in dialogs: OK set as default button (Enter submits) in `RowDialog` and `ConnectionDialog`; focus jumps to first input field on open; Enter in password field submits `ConnectionDialog`
- Light / dark theme toggle: `app/config/theme.py` holds Catppuccin Frappé (dark) + Latte (light) palettes; `resources/style_light.qss` is the full Latte QSS; toggle via View → Switch Theme (Ctrl+Shift+T) or Preferences → Color theme; on switch, QSS is reloaded, syntax highlighter rebuilds rules via `update_theme()`, completer popup recolors, and model dirty/NULL colors update live
### Not Yet Implemented ### Not Yet Implemented
**High impact:** **High impact:**
@@ -155,5 +156,4 @@ All four drivers have a `_where(where: dict) → (clause_str, params)` static he
**Medium impact:** **Medium impact:**
- Transaction panel: explicit BEGIN / COMMIT / ROLLBACK controls with in-flight indicator - Transaction panel: explicit BEGIN / COMMIT / ROLLBACK controls with in-flight indicator
- Global schema search: search across all tables/columns/procedures in a database - Global schema search: search across all tables/columns/procedures in a database
- Light theme: Catppuccin Latte or similar, toggle from Preferences
- Schema diff: compare two database schemas and show structural differences - Schema diff: compare two database schemas and show structural differences
+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.QtCore import Qt, QTimer, QThread, pyqtSignal
from PyQt6.QtGui import QAction, QColor, QKeySequence 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.schema_browser import SchemaBrowser
from app.ui.sql_editor import SQLEditorWidget 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("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("New SQL Tab", self._new_sql_tab, "Ctrl+T"))
view_menu.addAction(self._act("Close Tab", self._close_current_tab, "Ctrl+W")) 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 ─────────────────────────────────────────────────────────────
tools_menu = mb.addMenu("&Tools") tools_menu = mb.addMenu("&Tools")
@@ -662,9 +669,29 @@ class MainWindow(QMainWindow):
def _open_preferences(self): def _open_preferences(self):
dlg = PreferencesDialog(parent=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() 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): def _apply_settings_to_open_tabs(self):
"""Push updated settings to all currently open workspace tabs.""" """Push updated settings to all currently open workspace tabs."""
for i in range(self._workspace.count()): 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 PyQt6.QtGui import QColor, QFont
from app.config.theme import get_palette
class ResultTableModel(QAbstractTableModel): class ResultTableModel(QAbstractTableModel):
"""Immutable result-set model — replaces data via set_data().""" """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 NUM_ALIGN = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
TEXT_ALIGN = Qt.AlignmentFlag.AlignLeft | 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"] _FONT_FAMILIES = ["Consolas", "JetBrains Mono", "Fira Code", "Cascadia Code", "Courier New"]
_PAGE_SIZES = [50, 100, 500, 1000, 0] # 0 → All _PAGE_SIZES = [50, 100, 500, 1000, 0] # 0 → All
_PAGE_LABELS = ["50", "100", "500", "1 000", "All"] _PAGE_LABELS = ["50", "100", "500", "1 000", "All"]
_THEMES = [("dark", "🌙 Dark (Catppuccin Frappé)"),
("light", "☀️ Light (Catppuccin Latte)")]
class PreferencesDialog(QDialog): class PreferencesDialog(QDialog):
@@ -64,6 +66,18 @@ class PreferencesDialog(QDialog):
root.addWidget(results_box) 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 ───────────────────────────────────────────────────────────
general_box = QGroupBox("General") general_box = QGroupBox("General")
gf = QFormLayout(general_box) gf = QFormLayout(general_box)
@@ -82,7 +96,7 @@ class PreferencesDialog(QDialog):
root.addStretch() root.addStretch()
# ── Buttons ─────────────────────────────────────────────────────────── # ── Buttons ───────────────────────────────────────────────────────────
note = QLabel("Font changes take effect immediately in open editors.") note = QLabel("Theme and font changes take effect immediately.")
note.setObjectName("statusLabel") note.setObjectName("statusLabel")
root.addWidget(note) root.addWidget(note)
@@ -116,6 +130,10 @@ class PreferencesDialog(QDialog):
self._max_history.setValue(s.get("max_history", 500)) self._max_history.setValue(s.get("max_history", 500))
self._auto_commit.setChecked(s.get("auto_commit", True)) 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): def _apply(self):
s = get_settings() s = get_settings()
s.set("font_family", self._font_family.currentText()) s.set("font_family", self._font_family.currentText())
@@ -125,6 +143,7 @@ class PreferencesDialog(QDialog):
s.set("query_timeout", self._timeout.value()) s.set("query_timeout", self._timeout.value())
s.set("max_history", self._max_history.value()) s.set("max_history", self._max_history.value())
s.set("auto_commit", self._auto_commit.isChecked()) s.set("auto_commit", self._auto_commit.isChecked())
s.set("theme", _THEMES[self._theme.currentIndex()][0])
s.save() s.save()
self.settings_applied.emit() 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.ui.syntax_highlighter import _KEYWORDS, _TYPES, _FUNCTIONS
from app.utils.worker import SchemaWorker from app.utils.worker import SchemaWorker
from app.config.theme import get_palette
# Combined, deduplicated keyword list used as the static completion pool. # Combined, deduplicated keyword list used as the static completion pool.
SQL_KEYWORDS: list[str] = list(dict.fromkeys(_KEYWORDS + _TYPES + _FUNCTIONS)) SQL_KEYWORDS: list[str] = list(dict.fromkeys(_KEYWORDS + _TYPES + _FUNCTIONS))
@@ -51,24 +52,28 @@ class _CompletionPopup(QListWidget):
self.setFont(font) self.setFont(font)
self._row_h = QFontMetrics(font).height() + 6 self._row_h = QFontMetrics(font).height() + 6
self.setStyleSheet(""" self._apply_popup_style()
QListWidget { self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
background: #292c3c; self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
color: #c6d0f5;
border: 1px solid #51576d; 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; border-radius: 4px;
padding: 2px; padding: 2px;
outline: none; outline: none;
} }}
QListWidget::item { padding: 2px 8px; } QListWidget::item {{ padding: 2px 8px; }}
QListWidget::item:selected { QListWidget::item:selected {{
background: #414559; background: {p['popup_selected']};
color: #c6d0f5; color: {p['text']};
} }}
QListWidget::item:hover { background: #363a4f; } QListWidget::item:hover {{ background: {p['popup_hover']}; }}
""") """)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.itemClicked.connect( self.itemClicked.connect(
lambda item: self._editor._completer._accept_completion(item.text()) lambda item: self._editor._completer._accept_completion(item.text())
@@ -142,6 +147,10 @@ class SqlCompleter(QObject):
# ── Public API ──────────────────────────────────────────────────────────── # ── 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: def set_context(self, driver, database: str) -> None:
"""Update the active driver/database and refresh schema cache.""" """Update the active driver/database and refresh schema cache."""
self._driver = driver self._driver = driver
+3 -1
View File
@@ -471,7 +471,7 @@ class EditorTab(QWidget):
self._splitter.setHandleWidth(3) self._splitter.setHandleWidth(3)
self._editor = CodeEditor() self._editor = CodeEditor()
SQLHighlighter(self._editor.document()) self._highlighter = SQLHighlighter(self._editor.document())
self._completer = SqlCompleter(self._editor, parent=self) self._completer = SqlCompleter(self._editor, parent=self)
self._editor.set_completer(self._completer) self._editor.set_completer(self._completer)
if self._driver: if self._driver:
@@ -603,6 +603,8 @@ class EditorTab(QWidget):
def apply_settings(self) -> None: def apply_settings(self) -> None:
self._editor.apply_settings() self._editor.apply_settings()
self._highlighter.update_theme()
self._completer.update_theme()
# ── Tabbed SQL editor container ─────────────────────────────────────────────── # ── Tabbed SQL editor container ───────────────────────────────────────────────
+40 -47
View File
@@ -1,6 +1,7 @@
""" """
SQL syntax highlighter for QPlainTextEdit. SQL syntax highlighter for QPlainTextEdit.
Highlights keywords, types, functions, strings, comments, and numbers. Highlights keywords, types, functions, strings, comments, and numbers.
Colors follow the active Catppuccin theme (dark = Frappé, light = Latte).
""" """
import re import re
from PyQt6.QtCore import QRegularExpression, Qt from PyQt6.QtCore import QRegularExpression, Qt
@@ -8,6 +9,8 @@ from PyQt6.QtGui import (
QSyntaxHighlighter, QTextCharFormat, QColor, QFont QSyntaxHighlighter, QTextCharFormat, QColor, QFont
) )
from app.config.theme import get_palette
def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharFormat: def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharFormat:
f = QTextCharFormat() f = QTextCharFormat()
@@ -17,17 +20,7 @@ def _fmt(color: str, bold: bool = False, italic: bool = False) -> QTextCharForma
return f return f
# ── Token categories (Catppuccin Frappé palette) ───────────────────────────── # ── Keyword lists (shared with completer) ─────────────────────────────────────
_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 ─────────────────────────────────────────────────────────────
_KEYWORDS = [ _KEYWORDS = [
"SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
"FULL", "CROSS", "ON", "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "FULL", "CROSS", "ON", "AS", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN",
@@ -79,56 +72,56 @@ class SQLHighlighter(QSyntaxHighlighter):
def __init__(self, document): def __init__(self, document):
super().__init__(document) super().__init__(document)
self._rules: list[tuple] = [] self._rules: list[tuple] = []
self._build_rules()
def _build_rules(self):
p = get_palette()
def kw_pattern(words: list[str]) -> str: def kw_pattern(words: list[str]) -> str:
return r"\b(?:" + "|".join(words) + r")\b" return r"\b(?:" + "|".join(words) + r")\b"
ci = QRegularExpression.PatternOption.CaseInsensitiveOption
self._rules = [ self._rules = [
# Keywords (case-insensitive handled via flag) (QRegularExpression(kw_pattern(_KEYWORDS), ci),
(QRegularExpression(kw_pattern(_KEYWORDS), _fmt(p["syn_keyword"], bold=True)),
QRegularExpression.PatternOption.CaseInsensitiveOption), (QRegularExpression(kw_pattern(_TYPES), ci),
_KEYWORD_FMT), _fmt(p["syn_type"])),
# Data types (QRegularExpression(kw_pattern(_FUNCTIONS), ci),
(QRegularExpression(kw_pattern(_TYPES), _fmt(p["syn_function"])),
QRegularExpression.PatternOption.CaseInsensitiveOption), (QRegularExpression(r"\b\d+(\.\d+)?\b"),
_TYPE_FMT), _fmt(p["syn_number"])),
# Functions (QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"),
(QRegularExpression(kw_pattern(_FUNCTIONS), _fmt(p["syn_string"])),
QRegularExpression.PatternOption.CaseInsensitiveOption), (QRegularExpression(r'"[^"]*"'),
_FUNCTION_FMT), _fmt(p["syn_ident"])),
# Numbers (QRegularExpression(r"`[^`]*`"),
(QRegularExpression(r"\b\d+(\.\d+)?\b"), _NUMBER_FMT), _fmt(p["syn_ident"])),
# Single-quoted strings (QRegularExpression(
(QRegularExpression(r"'[^'\\]*(?:\\.[^'\\]*)*'"), _STRING_FMT), r"\bSELECT\s+\*|\*(?=\s*FROM)", ci),
# Double-quoted identifiers _fmt(p["syn_operator"], bold=True)),
(QRegularExpression(r'"[^"]*"'), _fmt("#99d1db")), (QRegularExpression(r"[=<>!%&|^~+\-*/]"),
# Backtick identifiers (MySQL) _fmt(p["syn_operator"])),
(QRegularExpression(r"`[^`]*`"), _fmt("#99d1db")), (QRegularExpression(r"--[^\n]*"),
# * wildcard _fmt(p["syn_comment"], italic=True)),
(QRegularExpression(r"\bSELECT\s+\*|\*(?=\s*FROM)", (QRegularExpression(r"#[^\n]*"),
QRegularExpression.PatternOption.CaseInsensitiveOption), _fmt(p["syn_comment"], italic=True)),
_STAR_FMT),
# Operators
(QRegularExpression(r"[=<>!%&|^~+\-*/]"), _OPERATOR_FMT),
# Single-line comment --
(QRegularExpression(r"--[^\n]*"), _COMMENT_FMT),
# Single-line comment #
(QRegularExpression(r"#[^\n]*"), _COMMENT_FMT),
] ]
self._comment_fmt = _fmt(p["syn_comment"], italic=True)
# Multi-line block comment /* ... */
self._block_comment_start = QRegularExpression(r"/\*") self._block_comment_start = QRegularExpression(r"/\*")
self._block_comment_end = 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: def highlightBlock(self, text: str) -> None:
# Single-line rules
for pattern, fmt in self._rules: for pattern, fmt in self._rules:
it = pattern.globalMatch(text) it = pattern.globalMatch(text)
while it.hasNext(): while it.hasNext():
m = it.next() m = it.next()
self.setFormat(m.capturedStart(), m.capturedLength(), fmt) self.setFormat(m.capturedStart(), m.capturedLength(), fmt)
# Multi-line block comments
self.setCurrentBlockState(0) self.setCurrentBlockState(0)
start_idx = 0 start_idx = 0
if self.previousBlockState() != 1: if self.previousBlockState() != 1:
@@ -139,10 +132,10 @@ class SQLHighlighter(QSyntaxHighlighter):
end_m = self._block_comment_end.match(text, start_idx) end_m = self._block_comment_end.match(text, start_idx)
if end_m.hasMatch(): if end_m.hasMatch():
end_idx = end_m.capturedStart() + end_m.capturedLength() 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) m = self._block_comment_start.match(text, end_idx)
start_idx = m.capturedStart() if m.hasMatch() else -1 start_idx = m.capturedStart() if m.hasMatch() else -1
else: else:
self.setCurrentBlockState(1) 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 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.utils.worker import TableDataWorker
from app.config.settings import get_settings 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) return "" if val is None else str(val)
if role == Qt.ItemDataRole.ForegroundRole: if role == Qt.ItemDataRole.ForegroundRole:
p = get_palette()
if self._is_deleted_row(r): 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: if (r, c) in self._dirty:
return QBrush(QColor("#ef9f76")) # orange — edited return QBrush(QColor(p["dirty_fg"]))
if val is None: if val is None:
return QBrush(QColor("#737994")) # grey — NULL return QBrush(QColor(p["overlay"]))
if role == Qt.ItemDataRole.BackgroundRole: if role == Qt.ItemDataRole.BackgroundRole:
p = get_palette()
if self._is_deleted_row(r): if self._is_deleted_row(r):
return QBrush(QColor("#3a2530")) return QBrush(QColor(p["delete_bg"]))
if (r, c) in self._dirty: if (r, c) in self._dirty:
return QBrush(QColor("#3a2f25")) return QBrush(QColor(p["dirty_bg"]))
return None return None
@@ -444,7 +447,7 @@ class TableViewer(QWidget):
self._frozen_view.horizontalHeader().setSectionResizeMode( self._frozen_view.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Fixed) QHeaderView.ResizeMode.Fixed)
self._frozen_view.setStyleSheet( 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() self._frozen_view.hide()
# Sync vertical scroll between the two views # Sync vertical scroll between the two views
@@ -887,6 +890,12 @@ class TableViewer(QWidget):
) )
QApplication.clipboard().setText("\n".join(statements)) 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): def _filter_by_cell(self, index):
if not index.isValid(): if not index.isValid():
return return
+2 -4
View File
@@ -9,15 +9,13 @@ from PyQt6.QtGui import QFont
from app.utils.logger import setup_logging, install_qt_message_handler, get_logger from app.utils.logger import setup_logging, install_qt_message_handler, get_logger
from app.main_window import MainWindow from app.main_window import MainWindow
from app.config.theme import apply_qss
log = get_logger(__name__) log = get_logger(__name__)
def load_stylesheet(app: QApplication) -> None: def load_stylesheet(app: QApplication) -> None:
style_path = os.path.join(os.path.dirname(__file__), "resources", "style.qss") apply_qss(app) # reads theme from settings, falls back to dark
if os.path.exists(style_path):
with open(style_path, "r", encoding="utf-8") as f:
app.setStyleSheet(f.read())
def main(): def main():
+599
View File
@@ -0,0 +1,599 @@
/* ═══════════════════════════════════════════════════════════════════════════
DBClient — Light Theme (Catppuccin Latte palette)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Variables via flat tokens ─────────────────────────────────────────────
base #eff1f5 crust #dce0e8
surface0 #ccd0da surface1 #bcc0cc surface2 #acb0be
overlay #9ca0b0 subtext #6c6f85 text #4c4f69
blue #1e66f5 lavender #7287fd sapphire #209fb5
green #40a02b teal #179299 sky #04a5e5
mauve #8839ef pink #ea76cb red #d20f39
peach #fe640b yellow #df8e1d
─────────────────────────────────────────────────────────────────────── */
/* ── Global reset ───────────────────────────────────────────────────────── */
* {
outline: none;
}
QMainWindow, QDialog {
background-color: #eff1f5;
color: #4c4f69;
}
QWidget {
background-color: #eff1f5;
color: #4c4f69;
font-family: "Segoe UI", "Inter", sans-serif;
font-size: 10pt;
}
/* ── Menu bar ───────────────────────────────────────────────────────────── */
QMenuBar {
background-color: #dce0e8;
color: #4c4f69;
border-bottom: 1px solid #ccd0da;
padding: 2px 0;
}
QMenuBar::item {
padding: 4px 12px;
border-radius: 4px;
}
QMenuBar::item:selected {
background-color: #ccd0da;
}
QMenu {
background-color: #e6e9ef;
border: 1px solid #bcc0cc;
border-radius: 6px;
padding: 4px;
}
QMenu::item {
padding: 6px 24px 6px 12px;
border-radius: 4px;
color: #4c4f69;
}
QMenu::item:selected {
background-color: #ccd0da;
color: #4c4f69;
}
QMenu::separator {
height: 1px;
background: #bcc0cc;
margin: 4px 8px;
}
/* ── Status bar ─────────────────────────────────────────────────────────── */
QStatusBar {
background-color: #dce0e8;
border-top: 1px solid #ccd0da;
color: #6c6f85;
font-size: 9pt;
padding: 2px 8px;
}
/* ── Scrollbars ─────────────────────────────────────────────────────────── */
QScrollBar:vertical {
background: #eff1f5;
width: 10px;
margin: 0;
}
QScrollBar::handle:vertical {
background: #bcc0cc;
border-radius: 5px;
min-height: 24px;
}
QScrollBar::handle:vertical:hover {
background: #acb0be;
}
QScrollBar:horizontal {
background: #eff1f5;
height: 10px;
}
QScrollBar::handle:horizontal {
background: #bcc0cc;
border-radius: 5px;
min-width: 24px;
}
QScrollBar::handle:horizontal:hover {
background: #acb0be;
}
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; }
QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
/* ── Splitter ────────────────────────────────────────────────────────────── */
QSplitter::handle {
background-color: #ccd0da;
}
QSplitter::handle:horizontal { width: 2px; }
QSplitter::handle:vertical { height: 2px; }
QSplitter::handle:hover {
background-color: #1e66f5;
}
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
#sidebarHeader {
background-color: #e6e9ef;
border-bottom: 1px solid #ccd0da;
}
#sidebarTitle {
color: #1e66f5;
font-size: 11pt;
font-weight: 600;
}
#newConnBtn {
background-color: #ccd0da;
color: #1e66f5;
border: 1px solid #bcc0cc;
border-radius: 6px;
font-size: 14pt;
padding: 0;
}
#newConnBtn:hover {
background-color: #bcc0cc;
color: #7287fd;
}
/* ── Tree widget ─────────────────────────────────────────────────────────── */
QTreeWidget {
background-color: #e6e9ef;
border: none;
color: #4c4f69;
font-size: 10pt;
show-decoration-selected: 1;
}
QTreeWidget::item {
padding: 3px 4px;
border-radius: 4px;
}
QTreeWidget::item:hover {
background-color: #d8dce8;
}
QTreeWidget::item:selected {
background-color: #ccd0da;
color: #4c4f69;
}
QTreeWidget::branch {
background: transparent;
}
/* ── Tab widget ──────────────────────────────────────────────────────────── */
QTabWidget::pane {
border: none;
border-top: 1px solid #ccd0da;
background-color: #eff1f5;
}
QTabBar {
background-color: #e6e9ef;
}
QTabBar::tab {
background-color: #e6e9ef;
color: #6c6f85;
padding: 7px 16px;
border: none;
border-right: 1px solid #ccd0da;
font-size: 10pt;
}
QTabBar::tab:selected {
background-color: #eff1f5;
color: #4c4f69;
border-bottom: 2px solid #1e66f5;
}
QTabBar::tab:hover:!selected {
background-color: #d0d4e0;
color: #4c4f69;
}
QTabBar::close-button {
subcontrol-position: right;
padding: 2px;
}
/* ── Table view ──────────────────────────────────────────────────────────── */
QTableView, QTableWidget {
background-color: #eff1f5;
alternate-background-color: #e6e9ef;
gridline-color: #ccd0da;
color: #4c4f69;
border: none;
selection-background-color: #ccd0da;
selection-color: #4c4f69;
font-size: 10pt;
}
QTableView::item, QTableWidget::item {
padding: 2px 6px;
}
QHeaderView {
background-color: #e6e9ef;
}
QHeaderView::section {
background-color: #e6e9ef;
color: #1e66f5;
border: none;
border-right: 1px solid #ccd0da;
border-bottom: 1px solid #ccd0da;
padding: 4px 8px;
font-weight: 600;
}
QHeaderView::section:hover {
background-color: #d0d4e0;
}
/* ── Plain text edit (SQL editor body) ───────────────────────────────────── */
QPlainTextEdit {
background-color: #eff1f5;
color: #4c4f69;
border: none;
selection-background-color: #bcc0cc;
font-family: "Consolas", "JetBrains Mono", "Courier New", monospace;
font-size: 13pt;
line-height: 1.5;
}
/* ── Line number gutter ──────────────────────────────────────────────────── */
LineNumberArea {
background-color: #dce0e8;
}
/* ── Buttons ─────────────────────────────────────────────────────────────── */
QPushButton {
background-color: #ccd0da;
color: #4c4f69;
border: 1px solid #bcc0cc;
border-radius: 6px;
padding: 5px 14px;
font-size: 10pt;
}
QPushButton:hover {
background-color: #bcc0cc;
border-color: #acb0be;
}
QPushButton:pressed {
background-color: #d0d4de;
}
QPushButton:disabled {
color: #acb0be;
background-color: #d0d4de;
border-color: #ccd0da;
}
#runBtn {
background-color: #40a02b;
color: #eff1f5;
font-weight: 700;
border: none;
}
#runBtn:hover {
background-color: #179299;
}
#stopBtn {
background-color: #d20f39;
color: #eff1f5;
font-weight: 700;
border: none;
}
#stopBtn:hover {
background-color: #e5394b;
}
#commitBtn {
background-color: #40a02b;
color: #eff1f5;
font-weight: 700;
border: none;
}
#rollbackBtn {
background-color: #fe640b;
color: #eff1f5;
font-weight: 700;
border: none;
}
#crudAddBtn {
background-color: #ccd0da;
color: #40a02b;
border: 1px solid #40a02b;
font-weight: 600;
}
#crudAddBtn:hover {
background-color: #40a02b;
color: #eff1f5;
}
#crudDeleteBtn {
background-color: #ccd0da;
color: #d20f39;
border: 1px solid #d20f39;
font-weight: 600;
}
#crudDeleteBtn:hover {
background-color: #d20f39;
color: #eff1f5;
}
/* ── Line edit ───────────────────────────────────────────────────────────── */
QLineEdit {
background-color: #e6e9ef;
color: #4c4f69;
border: 1px solid #bcc0cc;
border-radius: 6px;
padding: 5px 10px;
font-size: 10pt;
selection-background-color: #1e66f5;
selection-color: #eff1f5;
}
QLineEdit:focus {
border-color: #1e66f5;
}
QLineEdit::placeholder {
color: #acb0be;
}
/* ── Combo box ───────────────────────────────────────────────────────────── */
QComboBox {
background-color: #e6e9ef;
color: #4c4f69;
border: 1px solid #bcc0cc;
border-radius: 6px;
padding: 5px 10px;
font-size: 10pt;
}
QComboBox:focus {
border-color: #1e66f5;
}
QComboBox::drop-down {
border: none;
width: 24px;
}
QComboBox QAbstractItemView {
background-color: #e6e9ef;
color: #4c4f69;
border: 1px solid #bcc0cc;
selection-background-color: #ccd0da;
}
/* ── Spin box ────────────────────────────────────────────────────────────── */
QSpinBox {
background-color: #e6e9ef;
color: #4c4f69;
border: 1px solid #bcc0cc;
border-radius: 6px;
padding: 4px 8px;
}
QSpinBox:focus { border-color: #1e66f5; }
QSpinBox::up-button, QSpinBox::down-button {
background-color: #bcc0cc;
border: none;
border-radius: 3px;
width: 16px;
}
/* ── Check box ───────────────────────────────────────────────────────────── */
QCheckBox {
color: #4c4f69;
spacing: 8px;
}
QCheckBox::indicator {
width: 16px;
height: 16px;
border: 2px solid #bcc0cc;
border-radius: 4px;
background: #e6e9ef;
}
QCheckBox::indicator:checked {
background-color: #1e66f5;
border-color: #1e66f5;
}
/* ── Dialog ──────────────────────────────────────────────────────────────── */
QDialog {
background-color: #eff1f5;
}
QDialogButtonBox QPushButton {
min-width: 80px;
}
/* ── Form layout labels ──────────────────────────────────────────────────── */
QFormLayout QLabel {
color: #6c6f85;
}
/* ── Tab widget in dialogs ───────────────────────────────────────────────── */
QTabWidget#dialogTabs::pane {
border: 1px solid #ccd0da;
border-radius: 6px;
margin-top: -1px;
}
/* ── Group box ───────────────────────────────────────────────────────────── */
QGroupBox {
border: 1px solid #bcc0cc;
border-radius: 6px;
margin-top: 1em;
color: #6c6f85;
font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 4px;
}
/* ── Progress bar ────────────────────────────────────────────────────────── */
QProgressBar {
background-color: #ccd0da;
border: 1px solid #bcc0cc;
border-radius: 6px;
height: 10px;
text-align: center;
}
QProgressBar::chunk {
background-color: #1e66f5;
border-radius: 5px;
}
/* ── Dock widget ─────────────────────────────────────────────────────────── */
QDockWidget {
color: #4c4f69;
titlebar-close-icon: none;
font-weight: 600;
}
QDockWidget::title {
background-color: #e6e9ef;
padding: 6px;
border-bottom: 1px solid #ccd0da;
}
/* ── Tool button ─────────────────────────────────────────────────────────── */
QToolButton {
background-color: #ccd0da;
color: #4c4f69;
border: 1px solid #bcc0cc;
border-radius: 5px;
padding: 4px 8px;
font-size: 13pt;
}
QToolButton:hover {
background-color: #bcc0cc;
}
/* ── Message box ─────────────────────────────────────────────────────────── */
QMessageBox {
background-color: #eff1f5;
}
QMessageBox QLabel {
color: #4c4f69;
font-size: 10pt;
}
/* ── Empty workspace label ───────────────────────────────────────────────── */
#emptyLabel {
color: #acb0be;
font-size: 14pt;
line-height: 2;
}
/* ── Status label in results toolbar ────────────────────────────────────── */
#statusLabel {
color: #6c6f85;
font-size: 9pt;
padding-left: 4px;
}
/* ── DB label in SQL editor toolbar ─────────────────────────────────────── */
#dbLabel {
color: #9ca0b0;
font-size: 9pt;
padding-right: 4px;
}
/* ── Workspace tab widget ────────────────────────────────────────────────── */
#workspace > QTabBar::tab {
min-width: 120px;
}
/* ── Horizontal separator line in dialog ────────────────────────────────── */
QFrame[frameShape="4"] { /* HLine */
color: #ccd0da;
margin: 4px 0;
}
/* ── Structure view title ────────────────────────────────────────────────── */
#structureTitle {
color: #7287fd;
}
/* ── Pagination bar ──────────────────────────────────────────────────────── */
#paginationBar {
background-color: #e6e9ef;
border-top: 1px solid #bcc0cc;
min-height: 38px;
}
#pgBtn {
background-color: #d0d4de;
color: #4c4f69;
border: 1px solid #bcc0cc;
border-radius: 5px;
padding: 4px 10px;
font-size: 9pt;
min-width: 52px;
}
#pgBtn:hover {
background-color: #ccd0da;
border-color: #acb0be;
}
#pgBtn:disabled {
color: #bcc0cc;
background-color: #eff1f5;
border-color: #ccd0da;
}
#pageLbl {
color: #1e66f5;
font-size: 9pt;
font-weight: 600;
padding: 0 6px;
}
#rowCountLbl {
color: #6c6f85;
font-size: 9pt;
padding-left: 8px;
}