Coverage for paintmaskanimator/icons.py: 94%

46 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Theme-aware line icons for the application chrome. 

2 

3Every icon is a small 24×24 SVG drawn with a single stroke weight, so the 

4toolbars, timeline and panel headers share one visual language. The SVGs use 

5two placeholder colours that are substituted at render time: 

6 

7* ``currentColor`` — the foreground (the theme's text colour by default). 

8* ``ACCENT`` — the theme accent, used sparingly for a highlight detail. 

9 

10Icons are rendered at several pixel sizes so they stay crisp on Retina / 

11high-DPI screens, and cached per (name, colours) because the same icon is 

12requested from many widgets. 

13""" 

14from PySide6.QtCore import QByteArray, QRectF, Qt 

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

16from PySide6.QtSvg import QSvgRenderer 

17 

18from . import theme 

19 

20_STROKE = ( 

21 'fill="none" stroke="currentColor" stroke-width="1.8" ' 

22 'stroke-linecap="round" stroke-linejoin="round"' 

23) 

24 

25# Paths are drawn for this project (no third-party icon set is bundled). 

26_PATHS = { 

27 # --- drawing tools ----------------------------------------------------- 

28 "brush": ( 

29 '<path d="M19.5 4.5 10 14"/>' 

30 '<path d="M9.5 14.5c-2.4-.4-4.3 1.1-4.5 3.4-.1 1.2-.6 1.9-1.5 2.4 ' 

31 '3.2.9 7.2-.2 7.5-3.6" fill="ACCENT" stroke="ACCENT"/>' 

32 ), 

33 "line": ( 

34 '<path d="M6 18 18 6"/>' 

35 '<circle cx="5" cy="19" r="1.8" fill="ACCENT" stroke="ACCENT"/>' 

36 '<circle cx="19" cy="5" r="1.8" fill="ACCENT" stroke="ACCENT"/>' 

37 ), 

38 "shape": ( 

39 '<rect x="3.5" y="7.5" width="11" height="11" rx="1.2"/>' 

40 '<circle cx="15.5" cy="8.5" r="5" fill="ACCENT" fill-opacity="0.45" ' 

41 'stroke="ACCENT"/>' 

42 ), 

43 "bucket": ( 

44 '<path d="m4.5 11 7-7 7.5 7.5-7 7a2 2 0 0 1-2.8 0L4.5 13.8a2 2 0 0 1 0-2.8Z"/>' 

45 '<path d="M4.6 12.4h14"/>' 

46 '<path d="M20.5 15.5s1.5 2 1.5 3a1.5 1.5 0 0 1-3 0c0-1 1.5-3 1.5-3Z" ' 

47 'fill="ACCENT" stroke="ACCENT"/>' 

48 ), 

49 "closed_fill": ( 

50 '<path d="M12 3.5 20.5 12 12 20.5 3.5 12Z" fill="ACCENT" ' 

51 'fill-opacity="0.45" stroke-dasharray="5 3"/>' 

52 '<path d="M12 8.5 15.5 12 12 15.5 8.5 12Z"/>' 

53 ), 

54 "lasso": ( 

55 '<path d="M7 17.5C4.3 16.2 3 14.4 3 12c0-4.4 4-7.5 9-7.5s9 3.1 9 7.5-4 7.5-9 7.5"/>' 

56 '<path d="M8.5 15.5a2 2 0 1 0 0 4c1.4 0 1.5 2 .5 3"/>' 

57 ), 

58 "lasso_fill": ( 

59 '<path d="M7 17.5C4.3 16.2 3 14.4 3 12c0-4.4 4-7.5 9-7.5s9 3.1 9 7.5-4 7.5-9 7.5" ' 

60 'fill="ACCENT" fill-opacity="0.45"/>' 

61 '<path d="M8.5 15.5a2 2 0 1 0 0 4c1.4 0 1.5 2 .5 3"/>' 

62 '<path d="M14.5 8c1.6 1.9 2.5 3.2 2.5 4.1a2.5 2.5 0 0 1-5 0c0-.9.9-2.2 ' 

63 '2.5-4.1Z" fill="ACCENT" stroke="ACCENT"/>' 

64 ), 

65 "rect_select": ( 

66 '<path d="M4 7V5a1 1 0 0 1 1-1h2M11 4h2M17 4h2a1 1 0 0 1 1 1v2M20 11v2' 

67 'M20 17v2a1 1 0 0 1-1 1h-2M13 20h-2M7 20H5a1 1 0 0 1-1-1v-2M4 13v-2"/>' 

68 ), 

69 "auto_select": ( 

70 '<path d="m4 20 10-10"/>' 

71 '<path d="m12.5 11.5 2 2"/>' 

72 '<path d="M17 3v3M20.5 4.5l-2 2M21 9h-3M15 5.5l-1-1" stroke="ACCENT"/>' 

73 ), 

74 "eyedropper": ( 

75 '<path d="m14.5 6.5 3 3"/>' 

76 '<path d="m16 8-9.5 9.5-2.5.5.5-2.5L14 6"/>' 

77 '<path d="M14.5 4.5a2.1 2.1 0 0 1 3 0l2 2a2.1 2.1 0 0 1 0 3l-1.5 1.5-5-5Z" ' 

78 'fill="ACCENT" stroke="ACCENT"/>' 

79 ), 

80 "dust": ( 

81 '<path d="M4 20.5h7"/>' 

82 '<path d="M13.5 4.5 19.5 10.5l-6.4 6.4a1.5 1.5 0 0 1-2.1 0l-3.9-3.9' 

83 'a1.5 1.5 0 0 1 0-2.1Z"/>' 

84 '<path d="m10 8 6 6"/>' 

85 '<circle cx="5.5" cy="6" r="1.5" fill="ACCENT" stroke="ACCENT"/>' 

86 '<circle cx="9" cy="3.5" r="1" fill="ACCENT" stroke="ACCENT"/>' 

87 ), 

88 # --- timeline ---------------------------------------------------------- 

89 "play": '<path d="M7.5 5.2v13.6a.8.8 0 0 0 1.2.7l11-6.8a.8.8 0 0 0 0-1.4l-11-6.8a.8.8 0 0 0-1.2.7Z" fill="currentColor"/>', 

90 "pause": ( 

91 '<rect x="6.5" y="5" width="3.5" height="14" rx="1" fill="currentColor"/>' 

92 '<rect x="14" y="5" width="3.5" height="14" rx="1" fill="currentColor"/>' 

93 ), 

94 "prev_frame": '<path d="m14.5 6-6 6 6 6"/>', 

95 "next_frame": '<path d="m9.5 6 6 6-6 6"/>', 

96 "first_frame": '<path d="M5 5.5v13M17 6l-6 6 6 6"/>', 

97 "flip_horizontal": '<path d="M12 3v18" stroke-dasharray="2 2"/><path d="M3 18h5V6Z"/><path d="M21 18h-5V6Z" fill="currentColor"/>', 

98 "flip_vertical": '<path d="M3 12h18" stroke-dasharray="2 2"/><path d="M6 3v5h12Z"/><path d="M6 21v-5h12Z" fill="currentColor"/>', 

99 "last_frame": '<path d="M19 5.5v13M7 6l6 6-6 6"/>', 

100 "prev_key": '<path d="M6 5.5v13"/><path d="m17 6-6.5 6 6.5 6Z" fill="currentColor"/>', 

101 "next_key": '<path d="M18 5.5v13"/><path d="m7 6 6.5 6L7 18Z" fill="currentColor"/>', 

102 "frame_blank": ( 

103 '<rect x="4" y="4" width="16" height="16" rx="3" stroke-dasharray="3 2.6"/>' 

104 '<path d="M12 9v6M9 12h6"/>' 

105 ), 

106 "frame_extend": ( 

107 '<rect x="3.5" y="6" width="9" height="12" rx="2"/>' 

108 '<path d="M15.5 12h5M18 9.5v5"/>' 

109 ), 

110 "trash": ( 

111 '<path d="M4.5 6.5h15"/>' 

112 '<path d="M9.5 6.5V5a1.5 1.5 0 0 1 1.5-1.5h2A1.5 1.5 0 0 1 14.5 5v1.5"/>' 

113 '<path d="M6.5 6.5 7.3 19a1.6 1.6 0 0 0 1.6 1.5h6.2a1.6 1.6 0 0 0 1.6-1.5l.8-12.5"/>' 

114 ), 

115 "sort_numbers": ( 

116 '<path d="M7 5v14"/><path d="m4 16 3 3 3-3"/>' 

117 '<path d="M12.5 6.5h7.5M12.5 12h5.5M12.5 17.5h3.5"/>' 

118 ), 

119 "remap": ( 

120 '<rect x="5" y="4.5" width="14" height="16" rx="2"/>' 

121 '<path d="M9 3.5h6v2.5H9Z"/>' 

122 '<path d="M8.5 11h7M8.5 14.5h4.5"/>' 

123 ), 

124 "onion": ( 

125 '<rect x="8" y="8" width="12" height="12" rx="2.5"/>' 

126 '<path d="M5 15.5A2.5 2.5 0 0 1 4 13.5v-7A2.5 2.5 0 0 1 6.5 4h7a2.5 2.5 0 0 1 2 1" ' 

127 'stroke-opacity="0.5"/>' 

128 ), 

129 "settings": ( 

130 '<path d="M4 7h9M17 7h3M4 17h3M11 17h9"/>' 

131 '<circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/>' 

132 ), 

133 # --- generic chrome ---------------------------------------------------- 

134 "menu": '<path d="M4.5 7h15M4.5 12h15M4.5 17h15"/>', 

135 "close": '<path d="m6.5 6.5 11 11M17.5 6.5l-11 11"/>', 

136 "plus": '<path d="M12 5.5v13M5.5 12h13"/>', 

137 "minus": '<path d="M5.5 12h13"/>', 

138 "chevron_down": '<path d="m6.5 9.5 5.5 5.5 5.5-5.5"/>', 

139 "chevron_right": '<path d="m9.5 6.5 5.5 5.5-5.5 5.5"/>', 

140 "chevron_left": '<path d="m14.5 6.5-5.5 5.5 5.5 5.5"/>', 

141 "eye": ( 

142 '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/>' 

143 '<circle cx="12" cy="12" r="2.8"/>' 

144 ), 

145 "eye_off": ( 

146 '<path d="M10 5.7a8.8 8.8 0 0 1 2-.2c6 0 9.5 6.5 9.5 6.5a17 17 0 0 1-2.3 3.1' 

147 'M6.6 6.7C3.9 8.4 2.5 12 2.5 12S6 18.5 12 18.5a8.7 8.7 0 0 0 5.4-1.8"/>' 

148 '<path d="m3.5 3.5 17 17"/>' 

149 ), 

150 "draft": ( 

151 '<path d="M15.5 4.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"/>' 

152 '<path d="M13.5 6.5l3 3"/>' 

153 ), 

154 "folder_plus": ( 

155 '<path d="M3.5 7.5v10a2 2 0 0 0 2 2h13a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-6.5l-2-2.5h-4.5a2 2 0 0 0-2 2Z"/>' 

156 '<path d="M12 11v5M9.5 13.5h5"/>' 

157 ), 

158 "fit": ( 

159 '<path d="M4 9V5.5A1.5 1.5 0 0 1 5.5 4H9M15 4h3.5A1.5 1.5 0 0 1 20 5.5V9' 

160 'M20 15v3.5a1.5 1.5 0 0 1-1.5 1.5H15M9 20H5.5A1.5 1.5 0 0 1 4 18.5V15"/>' 

161 ), 

162 "zoom_actual": ( 

163 '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/>' 

164 '<path d="M9 8.5 10.5 7.5v6"/>' 

165 ), 

166 "compare": ( 

167 '<rect x="3.5" y="5" width="17" height="14" rx="1.5"/><path d="M12 3v18"/>' 

168 '<path d="M12 5h7a1.5 1.5 0 0 1 1.5 1.5v11A1.5 1.5 0 0 1 19 19h-7Z" ' 

169 'fill="ACCENT" stroke="none" opacity=".45"/>' 

170 ), 

171 # ブラックチェック:人の形を塗りつぶしたシルエット。 

172 "black_check": ( 

173 '<circle cx="12" cy="7.5" r="3.2" fill="currentColor"/>' 

174 '<path d="M5.5 20.5c.4-4.4 3-7 6.5-7s6.1 2.6 6.5 7Z" fill="currentColor"/>' 

175 ), 

176 # 隣接色分け:隣り合う区画を違う色で塗り分けた地図。 

177 "map_colors": ( 

178 '<path d="M3.5 6.5 9 4.5l6 2 5.5-2v13l-5.5 2-6-2-5.5 2Z"/>' 

179 '<path d="M9 4.5v13M15 6.5v13"/>' 

180 '<path d="M9 4.5l6 2v13l-6-2Z" fill="ACCENT" stroke="none" opacity=".55"/>' 

181 ), 

182 # 露光量:半分を塗った絞り(AE の露光量の調整と同じ見立て)。 

183 "exposure": ( 

184 '<circle cx="12" cy="12" r="8"/>' 

185 '<path d="M12 4a8 8 0 0 1 0 16Z" fill="currentColor"/>' 

186 ), 

187 # クリップボードへ書き出す:クリップのついた板。 

188 "clipboard": ( 

189 '<path d="M8.5 5.5H6.5a1.5 1.5 0 0 0-1.5 1.5v11.5A1.5 1.5 0 0 0 6.5 20h11' 

190 'a1.5 1.5 0 0 0 1.5-1.5V7a1.5 1.5 0 0 0-1.5-1.5h-2"/>' 

191 '<rect x="8.5" y="3" width="7" height="3.5" rx="1" fill="ACCENT" stroke="ACCENT"/>' 

192 '<path d="M8.5 11h7M8.5 14.5h4.5"/>' 

193 ), 

194 # フォルダーへ書き出す:カメラ。 

195 "camera": ( 

196 '<path d="M3.5 8.5h3.2l1.6-2.2h7.4l1.6 2.2h3.2a1 1 0 0 1 1 1v8.5a1 1 0 0 1-1 1' 

197 'h-17a1 1 0 0 1-1-1V9.5a1 1 0 0 1 1-1Z"/>' 

198 '<circle cx="12" cy="13.5" r="3.4" fill="ACCENT" stroke="ACCENT" opacity=".55"/>' 

199 '<circle cx="12" cy="13.5" r="3.4"/>' 

200 ), 

201 "rotate_reset": ( 

202 '<path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3"/><path d="M4.5 4.5v4h4"/>' 

203 ), 

204 "swap": '<path d="M7 4.5 4 7.5l3 3"/><path d="M4 7.5h11a4 4 0 0 1 4 4v1"/><path d="m17 19.5 3-3-3-3"/><path d="M20 16.5H9a4 4 0 0 1-4-4v-1"/>', 

205 "info": '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5.5M12 7.8v.01"/>', 

206 "success": '<circle cx="12" cy="12" r="8.5"/><path d="m8.2 12.2 2.6 2.6 5-5.2"/>', 

207 "warning": '<path d="M10.3 4.3 2.9 17.2A2 2 0 0 0 4.6 20h14.8a2 2 0 0 0 1.7-2.8L13.7 4.3a2 2 0 0 0-3.4 0Z"/><path d="M12 9.5v4M12 16.8v.01"/>', 

208 "error": '<circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6M15 9l-6 6"/>', 

209} 

210 

211# Pixel sizes pre-rendered into every QIcon. Qt picks the closest one for the 

212# requested logical size × device pixel ratio. 

213_RENDER_SIZES = (16, 20, 24, 32, 40, 48, 64) 

214_cache: dict = {} 

215 

216 

217def names(): 

218 return tuple(_PATHS) 

219 

220 

221def _svg(name, color, accent): 

222 body = _PATHS[name].replace("currentColor", color).replace("ACCENT", accent) 

223 return ( 

224 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ' 

225 f'{_STROKE.replace("currentColor", color)}>{body}</svg>' 

226 ) 

227 

228 

229def pixmap(name, size, color=None, accent=None, device_pixel_ratio=1.0): 

230 """Render one icon to a transparent pixmap of ``size`` logical pixels.""" 

231 c = theme.palette() 

232 color = QColor(color or c["text"]).name() 

233 accent = QColor(accent or c["accent"]).name() 

234 physical = max(1, round(size * device_pixel_ratio)) 

235 renderer = QSvgRenderer(QByteArray(_svg(name, color, accent).encode("utf-8"))) 

236 image = QPixmap(physical, physical) 

237 image.fill(Qt.GlobalColor.transparent) 

238 painter = QPainter(image) 

239 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

240 renderer.render(painter, QRectF(0, 0, physical, physical)) 

241 painter.end() 

242 image.setDevicePixelRatio(device_pixel_ratio) 

243 return image 

244 

245 

246def icon(name, color=None, accent=None, active_color=None): 

247 """Return a cached, multi-resolution QIcon for ``name``. 

248 

249 ``color`` defaults to the theme text colour and ``accent`` to the theme 

250 accent. Disabled states use the muted text colour; ``active_color`` (when 

251 given) is used for the checked/``On`` state, e.g. the pause glyph colour on 

252 an accent-filled play button. 

253 """ 

254 c = theme.palette() 

255 color = QColor(color or c["text"]).name() 

256 accent = QColor(accent or c["accent"]).name() 

257 muted = QColor(c["text_muted"]).name() 

258 key = (name, color, accent, muted, active_color) 

259 cached = _cache.get(key) 

260 if cached is not None: 

261 # Hand out copies: some bindings (e.g. the QtAds icon provider) take 

262 # ownership of the QIcon passed to them and would delete the cached one. 

263 return QIcon(cached) 

264 result = QIcon() 

265 for size in _RENDER_SIZES: 

266 result.addPixmap(pixmap(name, size, color, accent), QIcon.Mode.Normal) 

267 result.addPixmap(pixmap(name, size, muted, muted), QIcon.Mode.Disabled) 

268 if active_color: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 result.addPixmap( 

270 pixmap(name, size, active_color, active_color), 

271 QIcon.Mode.Normal, QIcon.State.On, 

272 ) 

273 _cache[key] = result 

274 return QIcon(result) 

275 

276 

277def clear_cache(): 

278 """Drop cached icons (call after a theme or accent change).""" 

279 _cache.clear()