Source code for qtframework.themes.theme_manager

"""Modern theme manager with JSON/YAML support.

This module provides a comprehensive theme management system that supports
built-in themes, custom theme loading from YAML files, and programmatic
theme creation.

Example:
    Create and register a custom theme::

        from qtframework.themes.theme_manager import ThemeManager
        from qtframework.themes.theme import Theme
        from qtframework.themes.tokens import DesignTokens, SemanticColors
        from pathlib import Path

        # Initialize theme manager
        theme_manager = ThemeManager(themes_dir=Path("my_themes"))

        # Use built-in themes
        theme_manager.set_theme("light")
        theme_manager.set_theme("dark")

        # Create a custom theme programmatically
        custom_theme = theme_manager.create_theme_from_colors(
            name="ocean",
            primary_color="#0077BE",
            background_color="#F0F8FF",
            is_dark=False,
            display_name="Ocean Theme",
            description="A calming blue ocean theme",
        )

        # Register the custom theme
        theme_manager.register_theme(custom_theme)
        theme_manager.set_theme("ocean")

        # Apply theme to application
        app = QApplication.instance()
        stylesheet = theme_manager.get_stylesheet()
        app.setStyleSheet(stylesheet)

        # Listen for theme changes
        theme_manager.theme_changed.connect(
            lambda name: print(f"Theme changed to: {name}")
        )

        # Export theme for sharing
        theme_manager.export_theme("ocean", "ocean_theme.yaml")

See Also:
    :class:`Theme`: Theme class with design tokens
    :mod:`qtframework.themes.tokens`: Design token system
    :class:`qtframework.core.application`: Application class that integrates themes
"""

from __future__ import annotations

from pathlib import Path

from PySide6.QtCore import QObject, Signal
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication

from qtframework.themes.builtin_themes import BUILTIN_THEMES
from qtframework.themes.font_loader import FontLoader
from qtframework.themes.theme import Theme
from qtframework.utils.logger import get_logger
from qtframework.utils.resources import ResourceManager


logger = get_logger(__name__)


[docs] class ThemeManager(QObject): """Modern theme manager for handling application themes.""" theme_changed = Signal(str) # Emits theme name when changed def __init__( self, themes_dir: Path | None = None, font_scale: int = 100, resource_manager: ResourceManager | None = None, excluded_builtin_themes: list[str] | None = None, excluded_themes: list[str] | None = None, included_themes: list[str] | None = None, include_auto_theme: bool = True, ) -> None: """Initialize theme manager. Args: themes_dir: Directory to load custom themes from (default: resources/themes) font_scale: Font scale percentage (50-200, default 100) resource_manager: Optional resource manager for themes and icons excluded_builtin_themes: List of built-in theme names to exclude from loading excluded_themes: List of custom theme names to exclude from loading included_themes: If specified, ONLY load these built-in framework themes (custom themes always load) include_auto_theme: Whether to include 'auto' theme in theme list (default: True) """ super().__init__() self._themes: dict[str, Theme] = {} self._current_theme_name: str = "" # Will be set after loading themes self._requested_theme_name: str = "" # Track what user requested (e.g., 'auto') self._resource_manager = resource_manager or ResourceManager() self._themes_dir = themes_dir or self._get_default_themes_dir() self._font_scale: int = font_scale self._excluded_builtin_themes = set(excluded_builtin_themes or []) self._excluded_themes = set(excluded_themes or []) # Keep included_themes as a list to preserve order (even if empty) self._included_themes = included_themes self._include_auto_theme = include_auto_theme # Load built-in themes self._load_builtin_themes() # Load custom themes self._load_custom_themes() # Set default theme to first available theme if not self._current_theme_name and self._themes: # If included_themes specified, use first from that list if self._included_themes: # Use first available theme from included list for theme_name in self._included_themes: if theme_name in self._themes: self._current_theme_name = theme_name break # If none found, use first available theme if not self._current_theme_name: self._current_theme_name = next(iter(self._themes.keys())) # Otherwise prefer light or dark if available elif "light" in self._themes and "light" not in self._excluded_builtin_themes: self._current_theme_name = "light" elif "dark" in self._themes and "dark" not in self._excluded_builtin_themes: self._current_theme_name = "dark" else: self._current_theme_name = next(iter(self._themes.keys())) self._requested_theme_name = self._current_theme_name def _get_default_themes_dir(self) -> Path: """Get default themes directory from resource manager. Returns: Path to themes directory """ # Use resource manager to find themes directory search_paths = self._resource_manager.get_search_paths("themes") if search_paths: return search_paths[0] return Path("resources/themes") def _load_builtin_themes(self) -> None: """Load all built-in themes based on inclusion/exclusion rules.""" for theme_name, theme_factory in BUILTIN_THEMES.items(): # If included_themes is specified (even if empty), only load themes in that list if self._included_themes is not None: if not self._included_themes or theme_name not in self._included_themes: logger.debug("Skipping non-included built-in theme: %s", theme_name) continue # Otherwise, skip excluded themes elif theme_name in self._excluded_builtin_themes: logger.debug("Skipping excluded built-in theme: %s", theme_name) continue try: theme = theme_factory() # Inject resource manager into theme's stylesheet generator theme._stylesheet_generator = theme._stylesheet_generator.__class__( self._resource_manager ) self._themes[theme_name] = theme logger.debug("Loaded built-in theme: %s", theme_name) except Exception as e: logger.exception("Failed to load built-in theme '%s': %s", theme_name, e) def _load_custom_themes(self) -> None: """Load custom themes from all search paths. Loads themes from all paths in the resource manager's theme search paths, allowing applications to provide their own themes that override framework themes. If a themes_dir was explicitly provided to the constructor, it is added to the search paths with highest priority. """ # Get all theme search paths from resource manager search_paths = self._resource_manager.get_search_paths("themes") # If themes_dir was explicitly provided (not from resource manager), # add it to search paths with highest priority if self._themes_dir not in search_paths: search_paths = [self._themes_dir, *search_paths] if not search_paths: logger.debug("No theme search paths configured") return # Load themes from all search paths (later paths can override earlier ones) for themes_dir in search_paths: if not themes_dir.exists(): logger.debug(f"Themes directory does not exist: {themes_dir}") continue # Load YAML themes only # Support both old structure (*.yaml) and new structure (*/config.yaml) # Load themes from subdirectories with config.yaml (new structure) for theme_file in themes_dir.glob("*/config.yaml"): self._load_theme_file(theme_file) # Load themes directly in themes directory (old structure, for backward compatibility) for theme_file in themes_dir.glob("*.yaml"): self._load_theme_file(theme_file) def _load_theme_file(self, theme_file: Path) -> None: """Load a theme from a YAML file. Args: theme_file: Path to the theme configuration file (*.yaml or config.yaml) Raises: ValueError: If theme file format is invalid yaml.YAMLError: If YAML parsing fails FileNotFoundError: If theme file does not exist """ try: if theme_file.suffix != ".yaml": logger.warning("Theme configuration files must use .yaml extension: %s", theme_file) return theme = Theme.from_yaml(theme_file) # Custom themes are always loaded unless explicitly excluded # The included_themes list only applies to built-in framework themes if theme.name in self._excluded_themes: logger.debug("Skipping excluded custom theme: %s", theme.name) return # Check for name conflicts - custom themes override built-in themes if theme.name in self._themes: logger.info( f"Custom theme '{theme.name}' from {theme_file} overrides built-in theme" ) # Note: Fonts are already loaded by Theme.from_yaml() # Inject resource manager into theme's stylesheet generator theme._stylesheet_generator = theme._stylesheet_generator.__class__( self._resource_manager ) self._themes[theme.name] = theme logger.info(f"Loaded custom theme '{theme.name}' from {theme_file}") except Exception as e: logger.exception("Failed to load theme from %s: %s", theme_file, e) def _load_theme_fonts(self, theme_dir: Path) -> None: """Load custom fonts for a theme. Args: theme_dir: Path to the theme directory (e.g., pserver_manager/themes/runescape) """ print(f"\n[Font Loader] Checking for fonts in: {theme_dir}") if not theme_dir.exists(): print(f" [Warning] Directory does not exist: {theme_dir}") return try: loaded_fonts = FontLoader.load_theme_fonts(theme_dir) if loaded_fonts: logger.info( f"Loaded {len(loaded_fonts)} custom fonts for theme in {theme_dir.name}" ) print(f" [Success] Loaded {len(loaded_fonts)} custom fonts") else: print(" [Info] No custom fonts found") except Exception as e: logger.warning(f"Failed to load fonts for theme {theme_dir.name}: {e}") print(f" [Error] Error loading fonts: {e}")
[docs] def register_theme(self, theme: Theme, override: bool = True) -> bool: """Register a theme programmatically. Args: theme: Theme to register override: If True, override existing theme with same name (default: True) Returns: True if registered successfully """ if theme.name in self._themes: if not override: logger.warning(f"Theme '{theme.name}' already exists") return False logger.info(f"Theme '{theme.name}' will override existing theme") # Inject resource manager into theme's stylesheet generator theme._stylesheet_generator = theme._stylesheet_generator.__class__(self._resource_manager) self._themes[theme.name] = theme logger.debug(f"Registered theme: {theme.name}") return True
[docs] def unregister_theme(self, theme_name: str) -> bool: """Unregister a theme. Args: theme_name: Name of the theme to unregister Returns: True if unregistered successfully """ if theme_name not in self._themes: logger.warning("Theme '%s' not found", theme_name) return False if theme_name == self._current_theme_name: logger.error("Cannot unregister the current theme") return False del self._themes[theme_name] logger.debug("Unregistered theme: %s", theme_name) return True
[docs] def get_theme(self, theme_name: str | None = None) -> Theme | None: """Get a theme by name. Args: theme_name: Theme name, or None for current theme Returns: Theme instance or None if not found """ name = theme_name or self._current_theme_name return self._themes.get(name)
[docs] def get_current_theme(self) -> Theme: """Get the current active theme. Returns: Current theme instance """ theme = self._themes.get(self._current_theme_name) if not theme: # Fallback to light theme if current theme is missing logger.error( f"Current theme '{self._current_theme_name}' not found, falling back to 'light'" ) self._current_theme_name = "light" theme = self._themes.get("light") if not theme: # Create a default light theme if even that's missing from qtframework.themes.builtin_themes import create_light_theme theme = create_light_theme() self._themes["light"] = theme return theme
[docs] def detect_system_theme(self) -> str: """Detect the system's light/dark mode preference. Returns: 'dark' if system is in dark mode, 'light' otherwise """ app = QApplication.instance() if app and isinstance(app, QApplication): palette = app.palette() # Check if window background is darker than text color bg_color = palette.color(QPalette.ColorRole.Window) # Calculate luminance (perceived brightness) luminance = ( 0.299 * bg_color.red() + 0.587 * bg_color.green() + 0.114 * bg_color.blue() ) / 255 # If luminance is less than 0.5, it's a dark theme if luminance < 0.5: logger.debug("Detected system dark mode") return "dark" logger.debug("Detected system light mode") return "light"
[docs] def set_theme(self, theme_name: str) -> bool: """Set the current theme. Args: theme_name: Name of the theme to activate (use 'auto' for system theme detection) Returns: True if theme was set successfully """ # Handle auto theme - detect system preference actual_theme_name = theme_name if theme_name == "auto": actual_theme_name = self.detect_system_theme() logger.info("Auto theme: detected system preference as '%s'", actual_theme_name) if actual_theme_name not in self._themes: logger.error("Theme '%s' not found", actual_theme_name) return False # Check if both the requested theme and actual theme are unchanged if ( actual_theme_name == self._current_theme_name and theme_name == self._requested_theme_name ): logger.debug("Theme '%s' is already active", actual_theme_name) return True old_theme = self._current_theme_name self._current_theme_name = actual_theme_name self._requested_theme_name = theme_name # Store what user requested logger.info( "Theme changed from '%s' to '%s' (requested: '%s')", old_theme, actual_theme_name, theme_name, ) # Emit signal for theme change with the actual theme (not 'auto') self.theme_changed.emit(actual_theme_name) return True
[docs] def set_font_scale(self, scale_percent: int) -> None: """Set the font scale percentage. Args: scale_percent: Font scale percentage (50-200) """ self._font_scale = scale_percent logger.debug("Font scale set to %d%%", scale_percent)
[docs] def get_stylesheet(self, theme_name: str | None = None) -> str: """Get the stylesheet for a theme. Args: theme_name: Theme name, or None for current theme Returns: Generated Qt stylesheet string """ theme = self.get_theme(theme_name) if not theme: logger.error("Theme '%s' not found", theme_name) return "" try: # Apply font scaling to a copy of the theme's tokens from copy import deepcopy tokens = deepcopy(theme.tokens) tokens.apply_font_scale(self._font_scale) # Generate stylesheet with scaled tokens return theme._stylesheet_generator.generate(tokens, theme.custom_styles) except Exception as e: logger.exception("Failed to generate stylesheet for theme '%s': %s", theme_name, e) return ""
[docs] def list_themes(self) -> list[str]: """List all available theme names. Returns: List of theme names with 'auto' first if enabled """ themes = list(self._themes.keys()) # Add 'auto' first in the list if enabled if self._include_auto_theme and "auto" not in themes: themes.insert(0, "auto") return themes
[docs] def get_theme_info(self, theme_name: str) -> dict[str, str] | None: """Get information about a theme. Args: theme_name: Name of the theme Returns: Theme information dictionary or None """ # Handle 'auto' theme specially if theme_name == "auto": return { "name": "auto", "display_name": "Auto (System)", "description": "Automatically match system light/dark mode preference", "author": "Qt Framework", "version": "1.0.0", } theme = self.get_theme(theme_name) if not theme: return None return { "name": theme.name, "display_name": theme.display_name, "description": theme.description, "author": theme.author, "version": theme.version, }
[docs] def export_theme(self, theme_name: str, export_path: str | Path, format: str = "json") -> bool: """Export a theme to a file. Args: theme_name: Name of the theme to export export_path: Path to export the theme to format: Export format ('yaml' or 'yml' only) Returns: True if exported successfully """ theme = self.get_theme(theme_name) if not theme: logger.error("Theme '%s' not found", theme_name) return False export_path = Path(export_path) try: if format not in {"yaml", "yml"}: logger.error("Only YAML export is supported. Got: %s", format) return False theme.save_yaml(export_path) logger.info("Exported theme '%s' to %s", theme_name, export_path) return True except Exception as e: logger.exception("Failed to export theme '%s': %s", theme_name, e) return False
[docs] def reload_themes(self) -> None: """Reload all custom themes from disk.""" # Remove all custom themes (keep built-in ones) custom_themes = [name for name in self._themes if name not in BUILTIN_THEMES] for theme_name in custom_themes: del self._themes[theme_name] # Reload custom themes self._load_custom_themes() logger.info("Reloaded custom themes")
[docs] def create_theme_from_colors( self, name: str, primary_color: str, background_color: str, is_dark: bool = False, **kwargs ) -> Theme: """Create a simple theme from basic color values. Args: name: Theme name primary_color: Primary accent color background_color: Background color is_dark: Whether this is a dark theme **kwargs: Additional theme properties Returns: New theme instance """ from qtframework.themes.tokens import DesignTokens, SemanticColors # Create tokens with basic colors tokens = DesignTokens() # Set semantic colors based on provided values if is_dark: tokens.semantic = SemanticColors( bg_primary=background_color, fg_primary="#FFFFFF", action_primary=primary_color, # ... other colors would be derived ) else: tokens.semantic = SemanticColors( bg_primary=background_color, fg_primary="#000000", action_primary=primary_color, # ... other colors would be derived ) return Theme( name=name, display_name=kwargs.get("display_name", name.title()), description=kwargs.get("description", f"Custom theme: {name}"), author=kwargs.get("author", "User"), version=kwargs.get("version", "1.0.0"), tokens=tokens, )