Coverage for src / ezqt_app / utils / icon_utils.py: 84.54%

77 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 13:12 +0000

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

2# ICON_UTILS - Icon and pixmap utility functions 

3# Project: ezqt_app 

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

5""" 

6Utility functions for manipulating icons and QPixmap objects. 

7Migrated from widgets/extended/menu_button.py. 

8""" 

9 

10from __future__ import annotations 

11 

12# /////////////////////////////////////////////////////////////// 

13# IMPORTS 

14# /////////////////////////////////////////////////////////////// 

15# Third-party imports 

16import requests 

17from PySide6.QtCore import QByteArray, QFile, QObject, QSize, Qt, QThread, Signal 

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

19from PySide6.QtSvg import QSvgRenderer 

20 

21 

22# /////////////////////////////////////////////////////////////// 

23# CLASSES 

24# /////////////////////////////////////////////////////////////// 

25class IconLoaderWorker(QThread): 

26 """Background thread that fetches an icon from a URL. 

27 

28 Signals 

29 ------- 

30 icon_loaded: 

31 Emitted with the resulting ``QIcon`` on success. 

32 load_failed: 

33 Emitted when the download or decoding fails. 

34 """ 

35 

36 icon_loaded = Signal(QIcon) 

37 load_failed = Signal() 

38 

39 def __init__(self, url: str, parent: QObject | None = None) -> None: 

40 super().__init__(parent) 

41 self._url = url 

42 

43 def run(self) -> None: 

44 try: 

45 response = requests.get(self._url, timeout=5) 

46 response.raise_for_status() 

47 if "image" not in response.headers.get("Content-Type", ""): 

48 self.load_failed.emit() 

49 return 

50 image_data = response.content 

51 if self._url.lower().endswith(".svg"): 51 ↛ 60line 51 didn't jump to line 60 because the condition on line 51 was always true

52 renderer = QSvgRenderer(QByteArray(image_data)) 

53 pixmap = QPixmap(QSize(16, 16)) 

54 pixmap.fill(Qt.GlobalColor.transparent) 

55 painter = QPainter(pixmap) 

56 renderer.render(painter) 

57 painter.end() 

58 self.icon_loaded.emit(QIcon(pixmap)) 

59 else: 

60 pixmap = QPixmap() 

61 if not pixmap.loadFromData(image_data): 

62 self.load_failed.emit() 

63 return 

64 pixmap = colorize_pixmap(pixmap, "#FFFFFF", 0.5) 

65 self.icon_loaded.emit(QIcon(pixmap)) 

66 except Exception: 

67 self.load_failed.emit() 

68 

69 

70# /////////////////////////////////////////////////////////////// 

71# FUNCTIONS 

72# /////////////////////////////////////////////////////////////// 

73 

74 

75def colorize_pixmap( 

76 pixmap: QPixmap, color: str = "#FFFFFF", opacity: float = 0.5 

77) -> QPixmap: 

78 """ 

79 Colorize a QPixmap with the given color and opacity. 

80 Args: 

81 pixmap: QPixmap to colorize. 

82 color: Color to apply (default: "#FFFFFF"). 

83 opacity: Opacity to apply (default: 0.5). 

84 Returns: 

85 QPixmap: The colorized pixmap. 

86 """ 

87 result = QPixmap(pixmap.size()) 

88 result.fill(Qt.GlobalColor.transparent) 

89 painter = QPainter(result) 

90 painter.setOpacity(opacity) 

91 painter.drawPixmap(0, 0, pixmap) 

92 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn) 

93 painter.fillRect(result.rect(), QColor(color)) 

94 painter.end() 

95 return result 

96 

97 

98def load_icon_from_source(source: QIcon | str | None) -> QIcon | None: 

99 """Load an icon from a local source (QIcon, file path, or Qt resource path). 

100 

101 Note: URL sources are not supported here — use :func:`load_icon_from_url_async` 

102 for HTTP/HTTPS URLs to avoid blocking the main thread. 

103 

104 Args: 

105 source: QIcon instance or local file/resource path. 

106 Returns: 

107 QIcon or None: The loaded icon, or None if loading failed or source is a URL. 

108 """ 

109 if source is None: 

110 return None 

111 if isinstance(source, QIcon): 

112 return source 

113 if isinstance(source, str): 113 ↛ 135line 113 didn't jump to line 135 because the condition on line 113 was always true

114 if source.startswith(("http://", "https://")): 

115 return None 

116 if source.lower().endswith(".svg"): 

117 try: 

118 file = QFile(source) 

119 if not file.open(QFile.OpenModeFlag.ReadOnly): 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true

120 raise ValueError("Failed to open SVG file.") 

121 renderer = QSvgRenderer(file) 

122 pixmap = QPixmap(QSize(16, 16)) 

123 pixmap.fill(Qt.GlobalColor.transparent) 

124 painter = QPainter(pixmap) 

125 renderer.render(painter) 

126 painter.end() 

127 return QIcon(pixmap) 

128 except Exception: 

129 return None 

130 pixmap = QPixmap(source) 

131 if pixmap.isNull(): 

132 return None 

133 pixmap = colorize_pixmap(pixmap, "#FFFFFF", 0.5) 

134 return QIcon(pixmap) 

135 return None 

136 

137 

138def load_icon_from_url_async( 

139 url: str, parent: QObject | None = None 

140) -> IconLoaderWorker: 

141 """Start an asynchronous icon download from a URL. 

142 

143 The caller must connect to the worker's signals before the download 

144 completes, and must keep a reference to the returned worker to prevent 

145 garbage collection. 

146 

147 Args: 

148 url: HTTP/HTTPS URL pointing to an image (PNG, JPEG, SVG, …). 

149 parent: Optional Qt parent for the worker thread. 

150 Returns: 

151 IconLoaderWorker: Running worker. Connect ``icon_loaded(QIcon)`` and 

152 ``load_failed()`` to react to the result. 

153 

154 Example:: 

155 

156 worker = load_icon_from_url_async("https://example.com/icon.png", parent=self) 

157 worker.icon_loaded.connect(self._on_icon_ready) 

158 worker.load_failed.connect(self._on_icon_error) 

159 """ 

160 worker = IconLoaderWorker(url, parent) 

161 worker.start() 

162 return worker