initial commit

This commit is contained in:
Filip
2026-03-11 16:06:00 +01:00
parent b3c69053f6
commit 5fd80e6dd6
127 changed files with 39684 additions and 0 deletions

539
theme.py Normal file
View 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;
}}
"""