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
- 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
+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
+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.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():
+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;
}