"""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; }} """