"""
Theme manager for handling dark/light mode switching.
This module provides a centralized theme management system that allows
switching between dark and light themes at runtime.
"""
from __future__ import annotations
import logging
import os
from enum import Enum
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication, QToolTip
from .resources import resource_path
logger = logging.getLogger(__name__)
[docs]
class Theme(Enum):
"""Available application themes."""
DARK = "dark"
LIGHT = "light"
[docs]
class ThemeManager:
"""
Manages application theme switching.
This singleton class handles loading and applying themes,
and persisting theme preferences.
"""
_instance: ThemeManager | None = None
_current_theme: Theme = Theme.LIGHT
def __new__(cls):
"""Ensure singleton instance."""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize theme manager."""
# Only initialize once
if not hasattr(self, "_initialized"):
self._initialized = True
self._load_saved_theme()
def _load_saved_theme(self) -> None:
"""Load saved theme preference from settings."""
# TODO: Load from QSettings or config file
# For now, default to light theme
self._current_theme = Theme.LIGHT
def _save_theme(self) -> None:
"""Save current theme preference to settings."""
# TODO: Save to QSettings or config file
pass
def get_current_theme(self) -> Theme:
"""
Get the currently active theme.
Returns:
Theme: Current theme enum value
"""
return self._current_theme
def get_theme_stylesheet_path(self, theme: Theme) -> str:
"""
Get the filesystem path to a theme's stylesheet.
Args:
theme: The theme to get the path for
Returns:
str: Absolute path to the theme's QSS file
"""
if theme == Theme.DARK:
filename = "dark_theme.qss"
else:
filename = "light_theme.qss"
return resource_path(os.path.join("styles", filename))
def load_stylesheet(self, theme: Theme) -> str:
"""
Load a theme's stylesheet content.
Args:
theme: The theme to load
Returns:
str: QSS stylesheet content
Raises:
FileNotFoundError: If stylesheet file doesn't exist
"""
import re
path = self.get_theme_stylesheet_path(theme)
if not os.path.exists(path):
raise FileNotFoundError(f"Theme stylesheet not found: {path}")
with open(path, encoding="utf-8") as f:
content = f.read()
base_dir = os.path.dirname(path)
def _resolve(match):
url = match.group(1).strip().strip("\"'")
if not os.path.isabs(url):
abs_url = os.path.normpath(os.path.join(base_dir, url))
return f"url({abs_url.replace(os.sep, '/')})"
return match.group(0)
return re.sub(r"url\(([^)]+)\)", _resolve, content)
def apply_theme(self, theme: Theme, app: QApplication | None = None) -> None:
"""
Apply a theme to the application.
Args:
theme: The theme to apply
app: QApplication instance. If None, uses QApplication.instance()
Raises:
ValueError: If app is None and no QApplication instance exists
"""
if app is None:
maybe_app = QApplication.instance()
app = maybe_app if isinstance(maybe_app, QApplication) else None
if app is None:
raise ValueError("No QApplication instance available")
try:
stylesheet = self.load_stylesheet(theme)
app.setStyleSheet(stylesheet)
self._current_theme = theme
self._save_theme()
except FileNotFoundError as e:
logger.warning("Could not load theme stylesheet: %s", e)
# Fallback to no stylesheet
app.setStyleSheet("")
# Set QToolTip palette explicitly — QSS QToolTip{} rules are ignored
# on some platforms (e.g. Windows native style) for non-main windows
tooltip_palette = QPalette()
if theme == Theme.DARK:
tooltip_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#334155"))
tooltip_palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#f1f5f9"))
else:
tooltip_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff"))
tooltip_palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#0f172a"))
QToolTip.setPalette(tooltip_palette)
def toggle_theme(self, app: QApplication | None = None) -> Theme:
"""
Toggle between dark and light themes.
Args:
app: QApplication instance. If None, uses QApplication.instance()
Returns:
Theme: The newly activated theme
"""
new_theme = Theme.LIGHT if self._current_theme == Theme.DARK else Theme.DARK
self.apply_theme(new_theme, app)
return new_theme
def is_dark_mode(self) -> bool:
"""
Check if dark mode is currently active.
Returns:
bool: True if dark mode, False if light mode
"""
return self._current_theme == Theme.DARK
def get_theme_color(self, color_name: str) -> str:
"""
Get a named color from the current theme's QSS file comments.
Parses the 'Base Colors' comment block for lines like:
Icon: #64748b (Slate 500 - for settings icon)
Args:
color_name: Name of the color (e.g. 'Icon', 'Primary', 'Text')
Returns:
str: Hex color string, or '#888888' as fallback
"""
import re
try:
qss_content = self.load_stylesheet(self._current_theme)
pattern = rf"{color_name}\s*:\s*(#[0-9a-fA-F]{{6}})"
match = re.search(pattern, qss_content)
if match:
return match.group(1)
except Exception:
logger.exception("Failed to resolve theme color '%s'", color_name)
return "#888888"
def get_theme_icon(self, theme: Theme) -> str:
"""
Get the icon character for a theme toggle button.
Args:
theme: The theme to get icon for
Returns:
str: Unicode character for the theme icon
"""
# Return icon for the OTHER theme (what clicking will switch TO)
if theme == Theme.DARK:
return "☀" # Sun icon (currently in light mode, will switch to dark)
else:
return "🌙" # Moon icon (currently in dark mode, will switch to light)
# Global theme manager instance
_theme_manager = ThemeManager()
[docs]
def get_theme_manager() -> ThemeManager:
"""
Get the global theme manager instance.
Returns:
ThemeManager: The singleton theme manager
"""
return _theme_manager