Coverage for src / ezqt_widgets / widgets / misc / theme_icon.py: 89.47%

101 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 22:46 +0000

1# /////////////////////////////////////////////////////////////// 

2# THEME_ICON - Theme-Aware Icon Widget 

3# Project: ezqt_widgets 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Theme-aware icon module. 

8 

9Provides a QIcon subclass that automatically adapts its color to match the 

10current application theme (light/dark) for PySide6 applications. 

11""" 

12 

13from __future__ import annotations 

14 

15# /////////////////////////////////////////////////////////////// 

16# IMPORTS 

17# /////////////////////////////////////////////////////////////// 

18# Standard library imports 

19import warnings 

20 

21# Third-party imports 

22from PySide6.QtCore import Qt 

23from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap 

24 

25# Local imports 

26from ...types import IconSourceExtended 

27 

28# /////////////////////////////////////////////////////////////// 

29# CLASSES 

30# /////////////////////////////////////////////////////////////// 

31 

32 

33class ThemeIcon(QIcon): 

34 """QIcon subclass with automatic theme-based color adaptation. 

35 

36 This icon adapts its color based on the specified theme: 

37 - Dark theme: icon rendered in the resolved dark color. 

38 - Light theme: icon rendered in the resolved light color. 

39 

40 The icon can be updated dynamically by calling :meth:`setTheme` 

41 when the application theme changes. 

42 

43 Args: 

44 icon: The source icon (``QIcon``, ``QPixmap``, or path string). 

45 theme: The initial theme (``"dark"`` or ``"light"``, default: ``"dark"``). 

46 dark_color: Optional color for dark theme (hex or ``rgb(...)`` string). 

47 light_color: Optional color for light theme (hex or ``rgb(...)`` string). 

48 

49 Raises: 

50 TypeError: If ``icon`` is ``None``. 

51 

52 Properties: 

53 theme: Get or set the active theme. 

54 original_icon: Get or set the source icon. 

55 

56 Example: 

57 >>> from ezqt_widgets.widgets.misc.theme_icon import ThemeIcon 

58 >>> # Basic usage with automatic white/black color adaptation 

59 >>> icon = ThemeIcon("path/to/icon.png", theme="dark") 

60 >>> button.setIcon(icon) 

61 >>> # Custom colors for each theme 

62 >>> icon = ThemeIcon("icon.png", dark_color="#FFFFFF", light_color="#333333") 

63 >>> # Factory method from any source (QIcon, QPixmap, path, or None) 

64 >>> themed = ThemeIcon.from_source("icon.svg", theme="light") 

65 >>> # Adapt to a new theme dynamically 

66 >>> icon.setTheme("light") 

67 """ 

68 

69 # /////////////////////////////////////////////////////////////// 

70 # INIT 

71 # /////////////////////////////////////////////////////////////// 

72 

73 def __init__( 

74 self, 

75 icon: IconSourceExtended, 

76 theme: str = "dark", 

77 dark_color: QColor | str | None = None, 

78 light_color: QColor | str | None = None, 

79 ) -> None: 

80 """Initialize the theme icon. 

81 

82 Args: 

83 icon: The source icon (``QIcon``, ``QPixmap``, or path string). 

84 theme: The initial theme (``"dark"`` or ``"light"``). 

85 dark_color: Optional color for dark theme (hex or ``rgb(...)`` string). 

86 light_color: Optional color for light theme (hex or ``rgb(...)`` string). 

87 

88 Raises: 

89 TypeError: If ``icon`` is ``None``. 

90 """ 

91 super().__init__() 

92 self._original_icon: QIcon = self._to_qicon(icon) 

93 self._theme: str = theme 

94 self._dark_color, self._light_color = self._resolve_theme_colors( 

95 dark_color, light_color 

96 ) 

97 self._update_icon() 

98 

99 # /////////////////////////////////////////////////////////////// 

100 # PROPERTIES 

101 # /////////////////////////////////////////////////////////////// 

102 

103 @property 

104 def theme(self) -> str: 

105 """Get the current theme. 

106 

107 Returns: 

108 The current theme (``"dark"`` or ``"light"``). 

109 """ 

110 return self._theme 

111 

112 @theme.setter 

113 def theme(self, value: str) -> None: 

114 """Set the current theme and update the icon color. 

115 

116 Args: 

117 value: The new theme (``"dark"`` or ``"light"``). 

118 """ 

119 if value not in ("dark", "light"): 

120 warnings.warn( 

121 f"ThemeIcon: invalid theme '{value}', expected 'dark' or 'light'.", 

122 stacklevel=2, 

123 ) 

124 return 

125 self._theme = value 

126 self._update_icon() 

127 

128 @property 

129 def original_icon(self) -> QIcon: 

130 """Get the original (uncolored) source icon. 

131 

132 Returns: 

133 The original icon. 

134 """ 

135 return self._original_icon 

136 

137 @original_icon.setter 

138 def original_icon(self, value: IconSourceExtended) -> None: 

139 """Set the source icon and refresh the themed version. 

140 

141 Args: 

142 value: The new icon source (``QIcon``, ``QPixmap``, or path string). 

143 

144 Raises: 

145 TypeError: If ``value`` is ``None``. 

146 """ 

147 self._original_icon = self._to_qicon(value) 

148 self._update_icon() 

149 

150 # /////////////////////////////////////////////////////////////// 

151 # PUBLIC METHODS 

152 # /////////////////////////////////////////////////////////////// 

153 

154 @classmethod 

155 def from_source( 

156 cls, 

157 source: IconSourceExtended, 

158 theme: str = "dark", 

159 dark_color: QColor | str | None = None, 

160 light_color: QColor | str | None = None, 

161 ) -> ThemeIcon | None: 

162 """Create a ThemeIcon from any supported source. 

163 

164 Args: 

165 source: The icon source (ThemeIcon, QIcon, QPixmap, or path string). 

166 theme: The initial theme (``"dark"`` or ``"light"``). 

167 dark_color: Optional color for dark theme (hex or ``rgb(...)`` string). 

168 light_color: Optional color for light theme (hex or ``rgb(...)`` string). 

169 

170 Returns: 

171 A ThemeIcon instance or None if ``source`` is None. 

172 """ 

173 if source is None: 

174 return None 

175 if isinstance(source, cls): 

176 return source 

177 return cls( 

178 source, 

179 theme=theme, 

180 dark_color=dark_color, 

181 light_color=light_color, 

182 ) 

183 

184 def setTheme(self, theme: str) -> None: 

185 """Update the icon color for the given theme. 

186 

187 Convenience method equivalent to setting the :attr:`theme` property. 

188 

189 Args: 

190 theme: The new theme (``"dark"`` or ``"light"``). 

191 """ 

192 self.theme = theme 

193 

194 # ------------------------------------------------ 

195 # PRIVATE METHODS 

196 # ------------------------------------------------ 

197 

198 @staticmethod 

199 def _normalize_color(value: QColor | str | None) -> QColor | None: 

200 """Normalize an input color value. 

201 

202 Args: 

203 value: Color input (QColor, hex string, or ``rgb(...)`` string). 

204 

205 Returns: 

206 A valid QColor, or None if input is invalid. 

207 """ 

208 if value is None: 

209 return None 

210 if isinstance(value, QColor): 

211 return value if value.isValid() else None 

212 color = QColor(value) 

213 if not color.isValid(): 

214 warnings.warn( 

215 f"ThemeIcon: invalid color '{value}', expected hex or rgb(...).", 

216 stacklevel=2, 

217 ) 

218 return None 

219 return color 

220 

221 @staticmethod 

222 def _invert_color(color: QColor) -> QColor: 

223 """Invert an RGB color. 

224 

225 Args: 

226 color: The color to invert. 

227 

228 Returns: 

229 The inverted color, preserving alpha. 

230 """ 

231 return QColor( 

232 255 - color.red(), 

233 255 - color.green(), 

234 255 - color.blue(), 

235 color.alpha(), 

236 ) 

237 

238 def _resolve_theme_colors( 

239 self, dark_color: QColor | str | None, light_color: QColor | str | None 

240 ) -> tuple[QColor, QColor]: 

241 """Resolve theme colors with fallback and auto-inversion. 

242 

243 If both are None, defaults to white/black. 

244 If only one is provided, the other is auto-inverted. 

245 """ 

246 normalized_dark = self._normalize_color(dark_color) 

247 normalized_light = self._normalize_color(light_color) 

248 

249 if normalized_dark is None and normalized_light is None: 

250 return QColor(Qt.GlobalColor.white), QColor(Qt.GlobalColor.black) 

251 

252 if normalized_dark is None and normalized_light is not None: 

253 return self._invert_color(normalized_light), normalized_light 

254 

255 if normalized_dark is not None and normalized_light is None: 

256 return normalized_dark, self._invert_color(normalized_dark) 

257 

258 if normalized_dark is None or normalized_light is None: 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true

259 raise RuntimeError( 

260 "ThemeIcon: unexpected state in _resolve_theme_colors; " 

261 "both colors should be non-None at this point." 

262 ) 

263 return normalized_dark, normalized_light 

264 

265 def _to_qicon(self, source: IconSourceExtended) -> QIcon: 

266 """Convert an icon source to a QIcon instance. 

267 

268 Args: 

269 source: The icon source to convert. 

270 

271 Returns: 

272 A QIcon instance. 

273 

274 Raises: 

275 TypeError: If ``source`` is ``None``. 

276 """ 

277 if source is None: 

278 raise TypeError( 

279 "ThemeIcon requires a non-None icon source " 

280 "(QIcon, QPixmap, or path string)." 

281 ) 

282 if isinstance(source, str): 

283 return QIcon(source) 

284 if isinstance(source, QPixmap): 

285 return QIcon(source) 

286 if isinstance(source, bytes): 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true

287 from PySide6.QtCore import QByteArray 

288 from PySide6.QtSvg import QSvgRenderer 

289 

290 renderer = QSvgRenderer(QByteArray(source)) 

291 pixmap = QPixmap(16, 16) 

292 pixmap.fill(Qt.GlobalColor.transparent) 

293 painter = QPainter(pixmap) 

294 renderer.render(painter) 

295 painter.end() 

296 return QIcon(pixmap) 

297 return source # QIcon or ThemeIcon (subclass of QIcon) 

298 

299 def _update_icon(self) -> None: 

300 """Recolor the icon to match the current theme. 

301 

302 - Dark theme: renders the icon in the resolved dark color. 

303 - Light theme: renders the icon in the resolved light color. 

304 """ 

305 if self._original_icon.isNull(): 

306 return 

307 

308 available_sizes = self._original_icon.availableSizes() 

309 if not available_sizes: 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 return 

311 

312 # Determine target color 

313 new_color = self._dark_color if self._theme == "dark" else self._light_color 

314 

315 # Build the recolored pixmap 

316 pixmap = self._original_icon.pixmap(available_sizes[0]) 

317 image = pixmap.toImage() 

318 

319 new_pixmap = QPixmap(image.size()) 

320 new_pixmap.fill(Qt.GlobalColor.transparent) 

321 

322 painter = QPainter(new_pixmap) 

323 painter.drawImage(0, 0, image) 

324 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) 

325 painter.fillRect(image.rect(), new_color) 

326 painter.end() 

327 

328 # Replace the current icon content with the recolored version 

329 self.swap(QIcon()) 

330 self.addPixmap(new_pixmap) 

331 

332 

333# /////////////////////////////////////////////////////////////// 

334# PUBLIC API 

335# /////////////////////////////////////////////////////////////// 

336 

337__all__ = ["ThemeIcon"]