From 06b951daf7652443be321f60cab4e513ab1b8d88 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 22 May 2026 09:11:05 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20dark/light=20theme=20toggle=20(Catppucc?= =?UTF-8?q?in=20Frapp=C3=A9=20=E2=86=94=20Latte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 2 +- app/config/theme.py | 121 +++++++ app/main_window.py | 29 +- app/models/result_table_model.py | 6 +- app/ui/preferences_dialog.py | 21 +- app/ui/sql_completer.py | 37 +- app/ui/sql_editor.py | 4 +- app/ui/syntax_highlighter.py | 87 +++-- app/ui/table_viewer.py | 21 +- main.py | 6 +- resources/style_light.qss | 599 +++++++++++++++++++++++++++++++ 11 files changed, 857 insertions(+), 76 deletions(-) create mode 100644 app/config/theme.py create mode 100644 resources/style_light.qss diff --git a/CLAUDE.md b/CLAUDE.md index ca1818c..16de520 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 - 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` +- 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 **High impact:** @@ -155,5 +156,4 @@ All four drivers have a `_where(where: dict) β†’ (clause_str, params)` static he **Medium impact:** - Transaction panel: explicit BEGIN / COMMIT / ROLLBACK controls with in-flight indicator - 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 diff --git a/app/config/theme.py b/app/config/theme.py new file mode 100644 index 0000000..a64ccb6 --- /dev/null +++ b/app/config/theme.py @@ -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")) diff --git a/app/main_window.py b/app/main_window.py index 9d28101..189e207 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -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()): diff --git a/app/models/result_table_model.py b/app/models/result_table_model.py index 0a02e0b..aa28949 100644 --- a/app/models/result_table_model.py +++ b/app/models/result_table_model.py @@ -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 diff --git a/app/ui/preferences_dialog.py b/app/ui/preferences_dialog.py index b75b55e..d8bfc6f 100644 --- a/app/ui/preferences_dialog.py +++ b/app/ui/preferences_dialog.py @@ -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() diff --git a/app/ui/sql_completer.py b/app/ui/sql_completer.py index f0c2a2c..0e5aa28 100644 --- a/app/ui/sql_completer.py +++ b/app/ui/sql_completer.py @@ -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 diff --git a/app/ui/sql_editor.py b/app/ui/sql_editor.py index d68a0ab..40cf5e2 100644 --- a/app/ui/sql_editor.py +++ b/app/ui/sql_editor.py @@ -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 ─────────────────────────────────────────────── diff --git a/app/ui/syntax_highlighter.py b/app/ui/syntax_highlighter.py index b6c90ef..1e682eb 100644 --- a/app/ui/syntax_highlighter.py +++ b/app/ui/syntax_highlighter.py @@ -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 diff --git a/app/ui/table_viewer.py b/app/ui/table_viewer.py index a241e09..277f3bd 100644 --- a/app/ui/table_viewer.py +++ b/app/ui/table_viewer.py @@ -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 diff --git a/main.py b/main.py index 2f1213d..35f1025 100644 --- a/main.py +++ b/main.py @@ -9,15 +9,13 @@ from PyQt6.QtGui import QFont from app.utils.logger import setup_logging, install_qt_message_handler, get_logger from app.main_window import MainWindow +from app.config.theme import apply_qss log = get_logger(__name__) def load_stylesheet(app: QApplication) -> None: - style_path = os.path.join(os.path.dirname(__file__), "resources", "style.qss") - if os.path.exists(style_path): - with open(style_path, "r", encoding="utf-8") as f: - app.setStyleSheet(f.read()) + apply_qss(app) # reads theme from settings, falls back to dark def main(): diff --git a/resources/style_light.qss b/resources/style_light.qss new file mode 100644 index 0000000..06e44a3 --- /dev/null +++ b/resources/style_light.qss @@ -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; +}