Coverage for paintmaskanimator/icons.py: 94%
46 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« 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.
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:
7* ``currentColor`` — the foreground (the theme's text colour by default).
8* ``ACCENT`` — the theme accent, used sparingly for a highlight detail.
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
18from . import theme
20_STROKE = (
21 'fill="none" stroke="currentColor" stroke-width="1.8" '
22 'stroke-linecap="round" stroke-linejoin="round"'
23)
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}
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 = {}
217def names():
218 return tuple(_PATHS)
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 )
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
246def icon(name, color=None, accent=None, active_color=None):
247 """Return a cached, multi-resolution QIcon for ``name``.
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)
277def clear_cache():
278 """Drop cached icons (call after a theme or accent change)."""
279 _cache.clear()