initial commit
This commit is contained in:
539
theme.py
Normal file
539
theme.py
Normal file
@@ -0,0 +1,539 @@
|
||||
"""Theme system for Encrypted Chat GUI — light + dark mode with live switching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, fields
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThemeColors:
|
||||
"""All colour tokens for one theme."""
|
||||
|
||||
# Surface hierarchy
|
||||
bg_primary: str # Main background (messages area, right panel)
|
||||
bg_secondary: str # Cards, inputs, elevated surfaces
|
||||
bg_tertiary: str # Sidebar, deeper surfaces
|
||||
bg_hover: str # Hover state on list items
|
||||
bg_selected: str # Selected list item
|
||||
|
||||
# Text
|
||||
text_primary: str # Main text
|
||||
text_secondary: str # Secondary / muted text
|
||||
text_muted: str # Timestamps, counters, hints
|
||||
|
||||
# Accent (brand blue)
|
||||
accent: str
|
||||
accent_hover: str
|
||||
accent_text: str # Text on accent background
|
||||
|
||||
# Message bubbles
|
||||
bubble_sent_bg: str
|
||||
bubble_sent_text: str
|
||||
bubble_recv_bg: str
|
||||
bubble_recv_text: str
|
||||
bubble_sent_meta: str # Timestamp/read inside sent bubble
|
||||
bubble_recv_meta: str # Timestamp inside received bubble
|
||||
|
||||
# Semantic colours
|
||||
success: str
|
||||
warning: str
|
||||
error: str
|
||||
info: str
|
||||
|
||||
# Chrome / borders
|
||||
border: str
|
||||
border_focus: str
|
||||
scrollbar: str
|
||||
separator: str
|
||||
overlay: str # Privacy overlay background (rgba)
|
||||
|
||||
# Links
|
||||
link_https: str
|
||||
link_http: str # Insecure link (orange)
|
||||
|
||||
# Mentions & search
|
||||
mention: str
|
||||
search_highlight: str
|
||||
search_current: str
|
||||
|
||||
# Reactions
|
||||
reaction_bg: str
|
||||
reaction_bg_own: str
|
||||
reaction_border: str
|
||||
reaction_border_own: str
|
||||
|
||||
# Misc
|
||||
online_dot: str
|
||||
online_dot_border: str
|
||||
pin_color: str
|
||||
sender_name_other: str # Non-self sender name colour in groups
|
||||
receipt_read: str # Read receipt checkmarks (must contrast with sent bubble bg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dark theme — Catppuccin Mocha palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DARK_THEME = ThemeColors(
|
||||
bg_primary="#1e1e2e",
|
||||
bg_secondary="#313244",
|
||||
bg_tertiary="#181825",
|
||||
bg_hover="#252536",
|
||||
bg_selected="#313244",
|
||||
|
||||
text_primary="#cdd6f4",
|
||||
text_secondary="#bac2de",
|
||||
text_muted="#6c7086",
|
||||
|
||||
accent="#89b4fa",
|
||||
accent_hover="#74c7ec",
|
||||
accent_text="#1e1e2e",
|
||||
|
||||
bubble_sent_bg="#2a4a7f",
|
||||
bubble_sent_text="#cdd6f4",
|
||||
bubble_recv_bg="#2c2c3e",
|
||||
bubble_recv_text="#cdd6f4",
|
||||
bubble_sent_meta="#8899bb",
|
||||
bubble_recv_meta="#6c7086",
|
||||
|
||||
success="#a6e3a1",
|
||||
warning="#f9e2af",
|
||||
error="#f38ba8",
|
||||
info="#74c7ec",
|
||||
|
||||
border="#45475a",
|
||||
border_focus="#89b4fa",
|
||||
scrollbar="#45475a",
|
||||
separator="#45475a",
|
||||
overlay="rgba(30, 30, 46, 245)",
|
||||
|
||||
link_https="#89b4fa",
|
||||
link_http="#fab387",
|
||||
|
||||
mention="#89b4fa",
|
||||
search_highlight="#f9e2af",
|
||||
search_current="#fab387",
|
||||
|
||||
reaction_bg="#313244",
|
||||
reaction_bg_own="#45475a",
|
||||
reaction_border="#45475a",
|
||||
reaction_border_own="#585b70",
|
||||
|
||||
online_dot="#a6e3a1",
|
||||
online_dot_border="#181825",
|
||||
pin_color="#f9e2af",
|
||||
sender_name_other="#f9e2af",
|
||||
receipt_read="#74c7ec",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Light theme — Signal-inspired palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LIGHT_THEME = ThemeColors(
|
||||
bg_primary="#ffffff",
|
||||
bg_secondary="#f2f2f7",
|
||||
bg_tertiary="#e5e5ea",
|
||||
bg_hover="#dcdce4",
|
||||
bg_selected="#c7c7d1",
|
||||
|
||||
text_primary="#1c1c1e",
|
||||
text_secondary="#3a3a3c",
|
||||
text_muted="#8a8a8e",
|
||||
|
||||
accent="#3478f6",
|
||||
accent_hover="#2563eb",
|
||||
accent_text="#ffffff",
|
||||
|
||||
bubble_sent_bg="#3478f6",
|
||||
bubble_sent_text="#ffffff",
|
||||
bubble_recv_bg="#e5e5ea",
|
||||
bubble_recv_text="#1c1c1e",
|
||||
bubble_sent_meta="#a3c4ff",
|
||||
bubble_recv_meta="#8a8a8e",
|
||||
|
||||
success="#34c759",
|
||||
warning="#ff9500",
|
||||
error="#ff3b30",
|
||||
info="#5ac8fa",
|
||||
|
||||
border="#c6c6c8",
|
||||
border_focus="#3478f6",
|
||||
scrollbar="#aeaeb2",
|
||||
separator="#c6c6c8",
|
||||
overlay="rgba(0, 0, 0, 200)",
|
||||
|
||||
link_https="#2563eb",
|
||||
link_http="#ea580c",
|
||||
|
||||
mention="#2563eb",
|
||||
search_highlight="#fde68a",
|
||||
search_current="#fb923c",
|
||||
|
||||
reaction_bg="#e5e5ea",
|
||||
reaction_bg_own="#c7c7d1",
|
||||
reaction_border="#c6c6c8",
|
||||
reaction_border_own="#a0a0a8",
|
||||
|
||||
online_dot="#34c759",
|
||||
online_dot_border="#e5e5ea",
|
||||
pin_color="#ff9500",
|
||||
sender_name_other="#7c3aed",
|
||||
receipt_read="#d0e8ff",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ThemeManager singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ThemeManager:
|
||||
"""Manages the active theme, persistence and change notification."""
|
||||
|
||||
_instance: ThemeManager | None = None
|
||||
|
||||
@classmethod
|
||||
def instance(cls) -> ThemeManager:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
self._is_dark: bool = True
|
||||
self._listeners: list[Callable[[], None]] = []
|
||||
self._email: str | None = None
|
||||
self._load_global()
|
||||
|
||||
# -- Public API --
|
||||
|
||||
@property
|
||||
def is_dark(self) -> bool:
|
||||
return self._is_dark
|
||||
|
||||
@property
|
||||
def colors(self) -> ThemeColors:
|
||||
return DARK_THEME if self._is_dark else LIGHT_THEME
|
||||
|
||||
def toggle(self):
|
||||
self._is_dark = not self._is_dark
|
||||
self._save()
|
||||
self._notify()
|
||||
|
||||
def set_dark(self, dark: bool):
|
||||
if dark == self._is_dark:
|
||||
return
|
||||
self._is_dark = dark
|
||||
self._save()
|
||||
self._notify()
|
||||
|
||||
def set_email(self, email: str):
|
||||
"""After login, bind to user-specific preference file."""
|
||||
self._email = email
|
||||
self._load_user()
|
||||
|
||||
def on_change(self, callback: Callable[[], None]):
|
||||
self._listeners.append(callback)
|
||||
|
||||
def remove_listener(self, callback: Callable[[], None]):
|
||||
try:
|
||||
self._listeners.remove(callback)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def generate_qss(self) -> str:
|
||||
return _build_qss(self.colors)
|
||||
|
||||
# -- Persistence --
|
||||
|
||||
def _global_path(self) -> Path:
|
||||
p = Path.home() / ".encrypted_chat"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p / "global_settings.json"
|
||||
|
||||
def _user_path(self) -> Path | None:
|
||||
if not self._email:
|
||||
return None
|
||||
p = Path.home() / ".encrypted_chat" / self._email
|
||||
if not p.exists():
|
||||
return None
|
||||
return p / "theme.json"
|
||||
|
||||
def _load_global(self):
|
||||
try:
|
||||
p = self._global_path()
|
||||
if p.exists():
|
||||
data = json.loads(p.read_text())
|
||||
self._is_dark = data.get("dark", True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _load_user(self):
|
||||
try:
|
||||
p = self._user_path()
|
||||
if p and p.exists():
|
||||
data = json.loads(p.read_text())
|
||||
self._is_dark = data.get("dark", self._is_dark)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save(self):
|
||||
data = {"dark": self._is_dark}
|
||||
try:
|
||||
self._global_path().write_text(json.dumps(data))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
p = self._user_path()
|
||||
if p:
|
||||
p.write_text(json.dumps(data))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _notify(self):
|
||||
for cb in list(self._listeners):
|
||||
try:
|
||||
cb()
|
||||
except Exception:
|
||||
logger.debug("Theme listener error", exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience accessors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def c() -> ThemeColors:
|
||||
"""Shorthand for ThemeManager.instance().colors."""
|
||||
return ThemeManager.instance().colors
|
||||
|
||||
|
||||
def qss() -> str:
|
||||
"""Shorthand for ThemeManager.instance().generate_qss()."""
|
||||
return ThemeManager.instance().generate_qss()
|
||||
|
||||
|
||||
def tm() -> ThemeManager:
|
||||
"""Shorthand for ThemeManager.instance()."""
|
||||
return ThemeManager.instance()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QSS generator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FONT_STACK = (
|
||||
'"Segoe UI Variable", "Segoe UI", "Helvetica Neue", '
|
||||
'"SF Pro Text", "Calibri", sans-serif'
|
||||
)
|
||||
|
||||
|
||||
def _build_qss(t: ThemeColors) -> str:
|
||||
return f"""
|
||||
/* ── Global ──────────────────────────────────────────────── */
|
||||
QWidget {{
|
||||
background-color: {t.bg_primary};
|
||||
color: {t.text_primary};
|
||||
font-family: {_FONT_STACK};
|
||||
font-size: 11pt;
|
||||
}}
|
||||
|
||||
/* ── Input fields ────────────────────────────────────────── */
|
||||
QLineEdit {{
|
||||
background-color: {t.bg_secondary};
|
||||
border: 1px solid {t.border};
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: 1px solid {t.border_focus};
|
||||
}}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────── */
|
||||
QPushButton {{
|
||||
background-color: {t.accent};
|
||||
color: {t.accent_text};
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {t.accent_hover};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: {t.accent_hover};
|
||||
}}
|
||||
QPushButton#secondaryBtn {{
|
||||
background-color: {t.bg_secondary};
|
||||
color: {t.text_primary};
|
||||
font-weight: normal;
|
||||
}}
|
||||
QPushButton#secondaryBtn:hover {{
|
||||
background-color: {t.bg_hover};
|
||||
}}
|
||||
QPushButton#toolBtn {{
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}}
|
||||
QPushButton#toolBtn:hover {{
|
||||
background-color: {t.bg_hover};
|
||||
}}
|
||||
|
||||
/* ── Lists ───────────────────────────────────────────────── */
|
||||
QListWidget {{
|
||||
background-color: {t.bg_tertiary};
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
}}
|
||||
QListWidget::item {{
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QListWidget::item:selected {{
|
||||
background-color: {t.bg_selected};
|
||||
border-left: 3px solid {t.accent};
|
||||
}}
|
||||
QListWidget::item:hover {{
|
||||
background-color: {t.bg_hover};
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── Text areas ──────────────────────────────────────────── */
|
||||
QTextEdit, QTextBrowser {{
|
||||
background-color: {t.bg_primary};
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── Scrollbar ───────────────────────────────────────────── */
|
||||
QScrollBar:vertical {{
|
||||
background: transparent;
|
||||
width: 8px;
|
||||
margin: 0;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {t.scrollbar};
|
||||
border-radius: 4px;
|
||||
min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{
|
||||
background: {t.text_muted};
|
||||
}}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
|
||||
height: 0;
|
||||
}}
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
|
||||
background: transparent;
|
||||
}}
|
||||
QScrollBar:horizontal {{
|
||||
background: transparent;
|
||||
height: 8px;
|
||||
margin: 0;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: {t.scrollbar};
|
||||
border-radius: 4px;
|
||||
min-width: 30px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{
|
||||
background: {t.text_muted};
|
||||
}}
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
|
||||
width: 0;
|
||||
}}
|
||||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
/* ── Title label ─────────────────────────────────────────── */
|
||||
QLabel#title {{
|
||||
font-size: 15pt;
|
||||
font-weight: bold;
|
||||
color: {t.accent};
|
||||
}}
|
||||
|
||||
/* ── Sidebar panel ───────────────────────────────────────── */
|
||||
#sidebarPanel {{
|
||||
background-color: {t.bg_tertiary};
|
||||
}}
|
||||
|
||||
/* ── Splitter ────────────────────────────────────────────── */
|
||||
QSplitter::handle {{
|
||||
background-color: {t.separator};
|
||||
width: 1px;
|
||||
}}
|
||||
|
||||
/* ── Checkbox ────────────────────────────────────────────── */
|
||||
QCheckBox {{
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── Menus ───────────────────────────────────────────────── */
|
||||
QMenu {{
|
||||
background-color: {t.bg_secondary};
|
||||
border: 1px solid {t.border};
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
}}
|
||||
QMenu::item {{
|
||||
padding: 6px 20px;
|
||||
color: {t.text_primary};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QMenu::item:selected {{
|
||||
background-color: {t.bg_hover};
|
||||
}}
|
||||
QMenu::separator {{
|
||||
height: 1px;
|
||||
background: {t.separator};
|
||||
margin: 4px 8px;
|
||||
}}
|
||||
|
||||
/* ── Dialogs ─────────────────────────────────────────────── */
|
||||
QDialog {{
|
||||
background-color: {t.bg_primary};
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── MessageBox ──────────────────────────────────────────── */
|
||||
QMessageBox {{
|
||||
background-color: {t.bg_primary};
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
QMessageBox QLabel {{
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── InputDialog ─────────────────────────────────────────── */
|
||||
QInputDialog {{
|
||||
background-color: {t.bg_primary};
|
||||
color: {t.text_primary};
|
||||
}}
|
||||
|
||||
/* ── ScrollArea ──────────────────────────────────────────── */
|
||||
QScrollArea {{
|
||||
background-color: {t.bg_primary};
|
||||
border: none;
|
||||
}}
|
||||
|
||||
/* ── ToolTip ─────────────────────────────────────────────── */
|
||||
QToolTip {{
|
||||
background-color: {t.bg_secondary};
|
||||
color: {t.text_primary};
|
||||
border: 1px solid {t.border};
|
||||
padding: 4px 8px;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
"""
|
||||
Reference in New Issue
Block a user