Coverage for src / ezqt_app / services / ui / theme_service.py: 58.73%
53 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 07:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 07:07 +0000
1"""
2Theme service implementation for UI styling.
3"""
5from __future__ import annotations
7# ///////////////////////////////////////////////////////////////
8# IMPORTS
9# ///////////////////////////////////////////////////////////////
10# Standard library imports
11import re
12from pathlib import Path
14# Local imports
15from ...domain.ports.main_window import MainWindowProtocol
16from ...utils.runtime_paths import get_bin_path
17from ..config import get_config_service
18from ..settings import get_settings_service
20# ///////////////////////////////////////////////////////////////
21# VARIABLES
22# ///////////////////////////////////////////////////////////////
23# Matches var(--variable_name) references in QSS stylesheets.
24_VAR_PATTERN: re.Pattern[str] = re.compile(r"var\(--([a-zA-Z0-9_]+)\)")
27# ///////////////////////////////////////////////////////////////
28# CLASSES
29# ///////////////////////////////////////////////////////////////
30class ThemeService:
31 """Service responsible for loading and applying QSS themes.
33 Theme variables are declared without prefix in theme config files
34 (e.g. ``main_surface``) and referenced in QSS files using the
35 standard CSS custom property notation ``var(--variable_name)``.
36 The service resolves each reference to its palette value before
37 applying the stylesheet.
38 """
40 @staticmethod
41 def apply_theme(window: MainWindowProtocol) -> None:
42 """Load all QSS files from the themes directory and apply the merged stylesheet.
44 All ``.qss`` files found under ``<app_root>/bin/themes/`` are loaded in
45 alphabetical order and concatenated before palette variables are resolved.
46 When that directory is absent or empty, the package's own
47 ``resources/themes/`` directory is used as fallback.
49 Args:
50 window: The application window whose ``ui.styleSheet`` will be
51 updated. Must expose ``window.ui.styleSheet.setStyleSheet``.
52 """
53 settings_service = get_settings_service()
54 config_service = get_config_service()
56 theme_preset = settings_service.gui.THEME_PRESET
57 theme_variant = settings_service.gui.THEME
58 theme_config = config_service.load_config("theme")
59 palette = theme_config.get("palette", {})
60 colors: dict[str, str] = palette.get(theme_preset, {}).get(theme_variant, {})
62 merged_style = ThemeService._load_themes_content()
63 merged_style = ThemeService._resolve_variables(merged_style, colors)
65 window.ui.style_sheet.setStyleSheet(f"{merged_style}\n")
67 @staticmethod
68 def get_available_themes() -> list[tuple[str, str]]:
69 """Return available theme options as ``(display_label, internal_value)`` pairs.
71 Reads ``palette`` keys from ``theme.config.yaml`` and generates one entry
72 per ``preset × variant`` combination, e.g.
73 ``("Blue Gray - Dark", "blue_gray:dark")``.
75 Returns:
76 List of ``(display_label, internal_value)`` tuples, one per theme variant.
77 """
78 config_service = get_config_service()
79 theme_config = config_service.load_config("theme")
80 palette = theme_config.get("palette", {})
82 options: list[tuple[str, str]] = []
83 for preset_key, variants in palette.items():
84 if not isinstance(variants, dict): 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 continue
86 display_preset = preset_key.replace("-", " ").title()
87 for variant_key in variants:
88 label = f"{display_preset} - {variant_key.title()}"
89 options.append((label, f"{preset_key}:{variant_key}"))
90 return options
92 @staticmethod
93 def _resolve_variables(stylesheet: str, colors: dict[str, str]) -> str:
94 """Replace all ``--var("name")`` tokens with their palette values.
96 Each token ``var(--name)`` is substituted with the value found under
97 the key ``name`` in *colors*. Unrecognised variable names are left
98 unchanged so that QSS parsing failures are easier to diagnose.
100 Args:
101 stylesheet: Raw QSS content containing ``var(--…)`` references.
102 colors: Mapping of variable name to CSS value, as loaded from the
103 active theme palette.
105 Returns:
106 The stylesheet with all resolvable variable references substituted.
107 """
109 def _replace(match: re.Match[str]) -> str:
110 var_name = match.group(1)
111 # Return the palette value when found; preserve the token otherwise.
112 return colors.get(var_name, match.group(0))
114 return _VAR_PATTERN.sub(_replace, stylesheet)
116 @staticmethod
117 def _load_themes_content() -> str:
118 """Load and merge all QSS files from the themes directory.
120 All ``.qss`` files are loaded in alphabetical order and joined with a
121 blank line separator so that rules from multiple files are concatenated
122 into a single stylesheet string.
124 Resolution order:
125 1. ``<app_root>/bin/themes/`` — project-local files (includes lib
126 defaults copied during initialisation and any developer additions).
127 2. Package ``resources/themes/`` — fallback when the local directory is
128 absent or contains no ``.qss`` files.
130 ``qtstrap.qss`` is excluded in both locations.
132 Returns:
133 Merged stylesheet content ready for variable resolution.
135 Raises:
136 FileNotFoundError: When no ``.qss`` files are found in either location.
137 """
138 _EXCLUDED = {"qtstrap.qss"}
140 local_themes_dir = get_bin_path() / "themes"
141 local_files = (
142 sorted(f for f in local_themes_dir.glob("*.qss") if f.name not in _EXCLUDED)
143 if local_themes_dir.is_dir()
144 else []
145 )
147 if local_files:
148 return "\n\n".join(f.read_text(encoding="utf-8") for f in local_files)
150 package_themes_dir = (
151 Path(__file__).resolve().parents[2] / "resources" / "themes"
152 )
153 package_files = (
154 sorted(
155 f for f in package_themes_dir.glob("*.qss") if f.name not in _EXCLUDED
156 )
157 if package_themes_dir.is_dir()
158 else []
159 )
161 if package_files:
162 return "\n\n".join(f.read_text(encoding="utf-8") for f in package_files)
164 raise FileNotFoundError(
165 "No theme files found in local (bin/themes/) or package resources."
166 )