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

1""" 

2Theme service implementation for UI styling. 

3""" 

4 

5from __future__ import annotations 

6 

7# /////////////////////////////////////////////////////////////// 

8# IMPORTS 

9# /////////////////////////////////////////////////////////////// 

10# Standard library imports 

11import re 

12from pathlib import Path 

13 

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 

19 

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_]+)\)") 

25 

26 

27# /////////////////////////////////////////////////////////////// 

28# CLASSES 

29# /////////////////////////////////////////////////////////////// 

30class ThemeService: 

31 """Service responsible for loading and applying QSS themes. 

32 

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

39 

40 @staticmethod 

41 def apply_theme(window: MainWindowProtocol) -> None: 

42 """Load all QSS files from the themes directory and apply the merged stylesheet. 

43 

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. 

48 

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() 

55 

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, {}) 

61 

62 merged_style = ThemeService._load_themes_content() 

63 merged_style = ThemeService._resolve_variables(merged_style, colors) 

64 

65 window.ui.style_sheet.setStyleSheet(f"{merged_style}\n") 

66 

67 @staticmethod 

68 def get_available_themes() -> list[tuple[str, str]]: 

69 """Return available theme options as ``(display_label, internal_value)`` pairs. 

70 

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")``. 

74 

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", {}) 

81 

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 

91 

92 @staticmethod 

93 def _resolve_variables(stylesheet: str, colors: dict[str, str]) -> str: 

94 """Replace all ``--var("name")`` tokens with their palette values. 

95 

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. 

99 

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. 

104 

105 Returns: 

106 The stylesheet with all resolvable variable references substituted. 

107 """ 

108 

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)) 

113 

114 return _VAR_PATTERN.sub(_replace, stylesheet) 

115 

116 @staticmethod 

117 def _load_themes_content() -> str: 

118 """Load and merge all QSS files from the themes directory. 

119 

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. 

123 

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. 

129 

130 ``qtstrap.qss`` is excluded in both locations. 

131 

132 Returns: 

133 Merged stylesheet content ready for variable resolution. 

134 

135 Raises: 

136 FileNotFoundError: When no ``.qss`` files are found in either location. 

137 """ 

138 _EXCLUDED = {"qtstrap.qss"} 

139 

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 ) 

146 

147 if local_files: 

148 return "\n\n".join(f.read_text(encoding="utf-8") for f in local_files) 

149 

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 ) 

160 

161 if package_files: 

162 return "\n\n".join(f.read_text(encoding="utf-8") for f in package_files) 

163 

164 raise FileNotFoundError( 

165 "No theme files found in local (bin/themes/) or package resources." 

166 )