Coverage for vibefpmgui/icons.py: 99%
479 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Small vector icons for the command toolbar, drawn with QPainter so they
2match the current text color (light or dark mode) at any screen scale."""
4from __future__ import annotations
6import math
7import re
8from functools import cache
9from pathlib import Path
11from qtpy.QtCore import QPointF, QRectF, Qt
12from qtpy.QtGui import (
13 QColor,
14 QIcon,
15 QLinearGradient,
16 QPainter,
17 QPainterPath,
18 QPen,
19 QPixmap,
20 QPolygonF,
21)
23SIZE = 24 # logical size; drawn at several pixel ratios for crisp HiDPI
26def _canvas(ratio: float) -> tuple[QPixmap, QPainter]:
27 pixmap = QPixmap(int(SIZE * ratio), int(SIZE * ratio))
28 pixmap.setDevicePixelRatio(ratio)
29 pixmap.fill(Qt.transparent)
30 painter = QPainter(pixmap)
31 painter.setRenderHint(QPainter.Antialiasing)
32 return pixmap, painter
35def _pen(color: QColor, width: float = 1.8) -> QPen:
36 return QPen(color, width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
39def _hammer(p: QPainter, c: QColor):
40 p.translate(12, 12)
41 p.rotate(-45)
42 p.setPen(Qt.NoPen)
43 p.setBrush(c)
44 p.drawRoundedRect(QRectF(-1.3, -3, 2.6, 13), 1.2, 1.2) # handle
45 head = QPainterPath()
46 head.addRoundedRect(QRectF(-6.5, -8, 11, 5.5), 1.5, 1.5)
47 p.drawPath(head)
48 p.drawRoundedRect(QRectF(4, -7.2, 2.6, 3.9), 0.8, 0.8) # striking face
51def _play(p: QPainter, c: QColor):
52 path = QPainterPath(QPointF(8, 5.5))
53 path.lineTo(18.5, 12)
54 path.lineTo(8, 18.5)
55 path.closeSubpath()
56 p.setPen(QPen(c, 2.2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
57 p.setBrush(c)
58 p.drawPath(path)
61def _flask(p: QPainter, c: QColor):
62 body = QPainterPath(QPointF(9.5, 4))
63 body.lineTo(9.5, 10)
64 body.lineTo(4.5, 18.2)
65 body.quadTo(3.6, 20, 5.6, 20)
66 body.lineTo(18.4, 20)
67 body.quadTo(20.4, 20, 19.5, 18.2)
68 body.lineTo(14.5, 10)
69 body.lineTo(14.5, 4)
70 p.setPen(_pen(c))
71 p.setBrush(Qt.NoBrush)
72 p.drawPath(body)
73 p.drawLine(QPointF(8, 4), QPointF(16, 4))
74 liquid = QPainterPath(QPointF(6.8, 14.5))
75 liquid.lineTo(17.2, 14.5)
76 liquid.lineTo(19.0, 17.6)
77 liquid.quadTo(19.6, 18.8, 18.2, 18.8)
78 liquid.lineTo(5.8, 18.8)
79 liquid.quadTo(4.4, 18.8, 5.0, 17.6)
80 liquid.closeSubpath()
81 p.setPen(Qt.NoPen)
82 p.setBrush(c)
83 p.drawPath(liquid)
86def _build_tests(p: QPainter, c: QColor):
87 """The hammer, smaller, with a small flask in front of it (bottom right)."""
88 p.save()
89 p.translate(-2, -2)
90 p.scale(0.78, 0.78)
91 _hammer(p, c)
92 p.restore()
93 p.save()
94 p.translate(9.6, 9.4)
95 p.scale(0.62, 0.62)
96 _flask(p, c)
97 p.restore()
100def _download(p: QPainter, c: QColor):
101 p.setPen(_pen(c, 2))
102 p.drawLine(QPointF(12, 3.5), QPointF(12, 14))
103 p.drawPolyline([QPointF(7.5, 9.8), QPointF(12, 14.3), QPointF(16.5, 9.8)])
104 tray = QPainterPath(QPointF(4, 14.5))
105 tray.lineTo(4, 18.5)
106 tray.quadTo(4, 20, 5.5, 20)
107 tray.lineTo(18.5, 20)
108 tray.quadTo(20, 20, 20, 18.5)
109 tray.lineTo(20, 14.5)
110 p.setBrush(Qt.NoBrush)
111 p.drawPath(tray)
114def _broom(p: QPainter, c: QColor):
115 p.setPen(_pen(c, 2))
116 p.drawLine(QPointF(18.5, 3.5), QPointF(12.6, 11.4)) # handle
117 bristles = QPainterPath(QPointF(10.2, 10.0))
118 bristles.lineTo(15.0, 13.6)
119 bristles.quadTo(13.8, 18.5, 9.8, 20.5)
120 bristles.lineTo(3.6, 15.9)
121 bristles.quadTo(7.6, 14.6, 10.2, 10.0)
122 bristles.closeSubpath()
123 p.setPen(_pen(c, 1.6))
124 p.setBrush(Qt.NoBrush)
125 p.drawPath(bristles)
126 p.drawLine(QPointF(7.2, 17.9), QPointF(9.6, 14.6))
127 p.drawLine(QPointF(10.0, 19.4), QPointF(12.0, 16.4))
130def _trash(p: QPainter, c: QColor):
131 p.setPen(_pen(c))
132 p.setBrush(Qt.NoBrush)
133 p.drawLine(QPointF(4.5, 6.5), QPointF(19.5, 6.5))
134 p.drawPolyline([QPointF(9.5, 6.5), QPointF(9.5, 4), QPointF(14.5, 4), QPointF(14.5, 6.5)])
135 can = QPainterPath(QPointF(6.2, 6.5))
136 can.lineTo(7.2, 18.6)
137 can.quadTo(7.4, 20, 8.8, 20)
138 can.lineTo(15.2, 20)
139 can.quadTo(16.6, 20, 16.8, 18.6)
140 can.lineTo(17.8, 6.5)
141 p.drawPath(can)
142 p.drawLine(QPointF(10.2, 10), QPointF(10.2, 16.5))
143 p.drawLine(QPointF(13.8, 10), QPointF(13.8, 16.5))
146def _bug(p: QPainter, c: QColor):
147 """A beetle: debugging."""
148 p.setPen(_pen(c))
149 p.setBrush(Qt.NoBrush)
150 p.drawEllipse(QRectF(6.8, 8.6, 10.4, 12.6)) # body
151 p.drawLine(QPointF(12, 9.2), QPointF(12, 20.8)) # (its wing cases' edge)
152 for side in (-1, 1): # legs and antennae, mirrored
153 for (x1, y1), (x2, y2) in (((7.4, 12.2), (3.6, 10.4)), ((6.9, 15.4), (3.2, 15.4)),
154 ((7.6, 18.6), (4.0, 20.6)), ((10.6, 5.2), (8.8, 2.6))):
155 p.drawLine(QPointF(12 + side * (x1 - 12), y1), QPointF(12 + side * (x2 - 12), y2))
156 p.setPen(Qt.NoPen)
157 p.setBrush(c)
158 p.drawEllipse(QPointF(12, 7.2), 3.2, 2.6) # head
161def _refresh(p: QPainter, c: QColor):
162 """A circular arrow: read again."""
163 p.setPen(_pen(c, 2))
164 p.setBrush(Qt.NoBrush)
165 arc = QPainterPath()
166 arc.arcMoveTo(QRectF(4.5, 4.5, 15, 15), 70)
167 arc.arcTo(QRectF(4.5, 4.5, 15, 15), 70, 290)
168 p.drawPath(arc)
169 end = arc.currentPosition() # (the circle's right: the head there, pointing on round it, up)
170 p.setPen(Qt.NoPen)
171 p.setBrush(c)
172 p.drawPolygon(QPolygonF([end + QPointF(-4, 0.5), end + QPointF(4, 0.5), end + QPointF(0, -4.5)]))
175def _unified(p: QPainter, c: QColor):
176 """A diff in one column (not two side by side): lines in one box."""
177 p.setPen(_pen(c, 1.8))
178 p.setBrush(Qt.NoBrush)
179 p.drawRoundedRect(QRectF(4, 3.5, 16, 17), 2.5, 2.5)
180 for y in (8, 12, 16):
181 p.drawLine(QPointF(7.5, y), QPointF(16.5 if y != 12 else 13.5, y))
184def _palette(p: QPainter, c: QColor):
185 """The Command Palette: a panel with a prompt (">") in its field, a list under it."""
186 p.setPen(_pen(c, 1.8))
187 p.setBrush(Qt.NoBrush)
188 p.drawRoundedRect(QRectF(3, 3, 18, 18), 2.5, 2.5)
189 p.drawLine(QPointF(3, 10.5), QPointF(21, 10.5)) # (the field's edge)
190 p.drawPolyline([QPointF(6.2, 5.2), QPointF(8.6, 6.9), QPointF(6.2, 8.6)]) # the prompt
191 p.drawLine(QPointF(10.2, 8.6), QPointF(14, 8.6))
192 for y, end in ((14, 17.5), (17.5, 14.5)): # the commands listed
193 p.drawLine(QPointF(6.5, y), QPointF(end, y))
196def _find_file(p: QPainter, c: QColor):
197 """Go to File: a page (its corner folded) under a magnifying glass."""
198 p.setPen(_pen(c, 1.7))
199 p.setBrush(Qt.NoBrush)
200 page = QPainterPath(QPointF(5, 3))
201 page.lineTo(12.5, 3)
202 page.lineTo(17, 7.5)
203 page.lineTo(17, 10.5)
204 page.moveTo(10, 20.5)
205 page.lineTo(5, 20.5)
206 page.lineTo(5, 3)
207 p.drawPath(page)
208 p.drawPolyline([QPointF(12.5, 3), QPointF(12.5, 7.5), QPointF(17, 7.5)]) # (the fold)
209 p.setPen(_pen(c, 2))
210 p.drawEllipse(QPointF(15, 15), 3.8, 3.8) # the glass
211 p.drawLine(QPointF(17.8, 17.8), QPointF(21, 21)) # its handle
214def _memcheck(p: QPainter, c: QColor):
215 """A memory chip under a magnifying glass: Valgrind's memory check."""
216 p.setPen(_pen(c, 1.6))
217 p.setBrush(Qt.NoBrush)
218 p.drawRoundedRect(QRectF(3.5, 5.5, 11, 11), 1.5, 1.5) # the chip
219 for i in (8, 11, 14): # its pins
220 p.drawLine(QPointF(i - 1.5, 3), QPointF(i - 1.5, 5.5))
221 p.drawLine(QPointF(i - 1.5, 16.5), QPointF(i - 1.5, 19))
222 p.drawLine(QPointF(1, i - 0.5), QPointF(3.5, i - 0.5))
223 p.setPen(_pen(c, 2))
224 p.setBrush(QColor(0, 0, 0, 0))
225 p.drawEllipse(QPointF(15, 15), 4.2, 4.2) # the glass
226 p.drawLine(QPointF(18, 18), QPointF(21.5, 21.5)) # its handle
229def _pencil(p: QPainter, c: QColor):
230 """Edit."""
231 p.translate(12, 12)
232 p.rotate(45)
233 p.setPen(_pen(c, 1.8))
234 p.setBrush(Qt.NoBrush)
235 body = QPainterPath(QPointF(-2.6, -9)) # (from the eraser end down to the point)
236 body.lineTo(2.6, -9)
237 body.lineTo(2.6, 4.5)
238 body.lineTo(0, 9.5)
239 body.lineTo(-2.6, 4.5)
240 body.closeSubpath()
241 p.drawPath(body)
242 p.drawLine(QPointF(-2.6, -5.5), QPointF(2.6, -5.5)) # (the eraser's band)
245def _save(p: QPainter, c: QColor):
246 """A floppy disk: save."""
247 p.setPen(_pen(c, 1.8))
248 p.setBrush(Qt.NoBrush)
249 disk = QPainterPath(QPointF(4.5, 6))
250 disk.quadTo(4.5, 4.5, 6, 4.5)
251 disk.lineTo(16, 4.5)
252 disk.lineTo(19.5, 8)
253 disk.lineTo(19.5, 18)
254 disk.quadTo(19.5, 19.5, 18, 19.5)
255 disk.lineTo(6, 19.5)
256 disk.quadTo(4.5, 19.5, 4.5, 18)
257 disk.closeSubpath()
258 p.drawPath(disk)
259 p.drawRect(QRectF(8, 4.5, 7, 4.5)) # (the shutter)
260 p.drawRect(QRectF(7.5, 13, 9, 6.5)) # (the label)
263def _menu(p: QPainter, c: QColor):
264 """Three lines: a menu of more."""
265 p.setPen(_pen(c, 2))
266 for y in (7, 12, 17):
267 p.drawLine(QPointF(5, y), QPointF(19, y))
270def _pause(p: QPainter, c: QColor):
271 p.setPen(Qt.NoPen)
272 p.setBrush(c)
273 p.drawRoundedRect(QRectF(6.5, 5, 4, 14), 1.2, 1.2)
274 p.drawRoundedRect(QRectF(13.5, 5, 4, 14), 1.2, 1.2)
277def _continue(p: QPainter, c: QColor):
278 """A bar, then the play triangle: run on from where it's paused."""
279 p.setPen(Qt.NoPen)
280 p.setBrush(c)
281 p.drawRoundedRect(QRectF(4.5, 5.5, 3, 13), 1, 1)
282 p.translate(2.5, 0)
283 _play(p, c)
286def _step_dot(p: QPainter, c: QColor):
287 """The line a step lands on, as a dot under its arrow."""
288 p.setPen(Qt.NoPen)
289 p.setBrush(c)
290 p.drawEllipse(QPointF(12, 19.5), 2.3, 2.3)
293def _step_over(p: QPainter, c: QColor):
294 """An arrow over the dot: the call run, not gone into."""
295 p.setPen(_pen(c, 2))
296 p.setBrush(Qt.NoBrush)
297 arc = QPainterPath(QPointF(4.5, 14))
298 arc.cubicTo(QPointF(4.5, 3.5), QPointF(19.5, 3.5), QPointF(19.5, 13.5))
299 p.drawPath(arc)
300 p.drawPolyline([QPointF(16.2, 10.6), QPointF(19.5, 14), QPointF(22.5, 10.6)])
301 _step_dot(p, c)
304def _step_into(p: QPainter, c: QColor):
305 """An arrow down to the dot: into the procedure called."""
306 p.setPen(_pen(c, 2))
307 p.drawLine(QPointF(12, 3), QPointF(12, 14))
308 p.drawPolyline([QPointF(8, 10.2), QPointF(12, 14.2), QPointF(16, 10.2)])
309 _step_dot(p, c)
312def _step_out(p: QPainter, c: QColor):
313 """An arrow up, away from the dot: out to the caller."""
314 p.setPen(_pen(c, 2))
315 p.drawLine(QPointF(12, 3), QPointF(12, 14.5))
316 p.drawPolyline([QPointF(8, 7), QPointF(12, 3), QPointF(16, 7)])
317 _step_dot(p, c)
320def _stop(p: QPainter, c: QColor):
321 p.setPen(Qt.NoPen)
322 p.setBrush(c)
323 p.drawRoundedRect(QRectF(6, 6, 12, 12), 2.5, 2.5)
326def _robot(p: QPainter, c: QColor):
327 p.setPen(_pen(c, 1.7))
328 p.setBrush(Qt.NoBrush)
329 # Antenna with a ball on top.
330 p.drawLine(QPointF(12, 4.6), QPointF(12, 7.2))
331 p.setPen(Qt.NoPen)
332 p.setBrush(c)
333 p.drawEllipse(QPointF(12, 3.4), 1.6, 1.6)
334 # Head.
335 p.setPen(_pen(c, 1.7))
336 p.setBrush(Qt.NoBrush)
337 p.drawRoundedRect(QRectF(4.5, 7.2, 15, 12.3), 3.6, 3.6)
338 # Ears.
339 p.drawLine(QPointF(2.6, 11.6), QPointF(2.6, 15.0))
340 p.drawLine(QPointF(21.4, 11.6), QPointF(21.4, 15.0))
341 # Eyes and mouth.
342 p.setPen(Qt.NoPen)
343 p.setBrush(c)
344 p.drawEllipse(QPointF(9.0, 12.3), 1.7, 1.7)
345 p.drawEllipse(QPointF(15.0, 12.3), 1.7, 1.7)
346 p.setPen(_pen(c, 1.5))
347 p.drawLine(QPointF(9.6, 16.2), QPointF(14.4, 16.2))
350def _format(p: QPainter, c: QColor):
351 # Lines of code, indented as a formatter would.
352 p.setPen(_pen(c, 2.0))
353 for y, x0, x1 in ((5.5, 3.5, 14), (10, 8, 20.5), (14.5, 8, 17), (19, 3.5, 12)):
354 p.drawLine(QPointF(x0, y), QPointF(x1, y))
355 # A small sparkle.
356 path = QPainterPath(QPointF(19.5, 14.5))
357 for x, y in ((20.4, 17.1), (23, 18), (20.4, 18.9), (19.5, 21.5), (18.6, 18.9), (16, 18), (18.6, 17.1)):
358 path.lineTo(x, y)
359 path.closeSubpath()
360 p.setPen(Qt.NoPen)
361 p.setBrush(c)
362 p.drawPath(path)
365def _more(p: QPainter, c: QColor):
366 # ">>": the toolbar's overflow button.
367 p.setPen(_pen(c, 1.9))
368 for x in (6.5, 12.5):
369 p.drawPolyline([QPointF(x, 7), QPointF(x + 5, 12), QPointF(x, 17)])
372def _close(p: QPainter, c: QColor):
373 # A small "×" (editor tab close buttons).
374 p.setPen(_pen(c, 2.2))
375 p.drawLine(QPointF(7, 7), QPointF(17, 17))
376 p.drawLine(QPointF(17, 7), QPointF(7, 17))
379def _coverage(p: QPainter, c: QColor):
380 """A gutter of lines, most of them run (filled), one not (outlined)."""
381 p.setPen(_pen(c))
382 p.setBrush(Qt.NoBrush)
383 p.drawRoundedRect(QRectF(4, 4, 16, 16), 2.5, 2.5)
384 p.setPen(Qt.NoPen)
385 p.setBrush(c)
386 for y, width in ((7, 8), (11, 11), (15, 6)):
387 p.drawRect(QRectF(7, y, width, 2))
388 p.setPen(_pen(c, 1.4))
389 p.setBrush(Qt.NoBrush)
390 p.drawRect(QRectF(13.5, 14.5, 3.5, 3))
393def _complexity(p: QPainter, c: QColor):
394 """A decision (a diamond) and its two ways."""
395 p.setPen(_pen(c, 1.8))
396 p.setBrush(Qt.NoBrush)
397 p.drawPolygon([QPointF(12, 3), QPointF(17, 8), QPointF(12, 13), QPointF(7, 8)])
398 p.drawLine(QPointF(7, 8), QPointF(5, 8))
399 p.drawLine(QPointF(5, 8), QPointF(5, 16.5))
400 p.drawLine(QPointF(17, 8), QPointF(19, 8))
401 p.drawLine(QPointF(19, 8), QPointF(19, 16.5))
402 p.drawRoundedRect(QRectF(2.5, 16.5, 5, 4.5), 1.2, 1.2)
403 p.drawRoundedRect(QRectF(16.5, 16.5, 5, 4.5), 1.2, 1.2)
406def _git(p: QPainter, c: QColor):
407 """A branch: two commits on a line, and one branching off it."""
408 p.setPen(_pen(c, 1.9))
409 p.setBrush(Qt.NoBrush)
410 p.drawLine(QPointF(7, 8.6), QPointF(7, 15.4))
411 path = QPainterPath(QPointF(17, 9.6))
412 path.cubicTo(QPointF(17, 14), QPointF(7, 12), QPointF(7, 15.4))
413 p.drawPath(path)
414 for x, y in ((7, 6), (7, 18), (17, 7)):
415 p.drawEllipse(QPointF(x, y), 2.6, 2.6)
418DRAWERS = {
419 "build": _hammer,
420 "build-tests": _build_tests,
421 "run": _play,
422 "test": _flask,
423 "coverage": _coverage,
424 "git": _git,
425 "complexity": _complexity,
426 "update": _download,
427 "clean": _broom,
428 "clean-all": _trash,
429 "stop": _stop,
430 "pause": _pause,
431 "pencil": _pencil,
432 "memcheck": _memcheck,
433 "palette": _palette,
434 "find-file": _find_file,
435 "save": _save,
436 "menu": _menu,
437 "refresh": _refresh,
438 "unified": _unified,
439 "bug": _bug,
440 "continue": _continue,
441 "step-over": _step_over,
442 "step-into": _step_into,
443 "step-out": _step_out,
444 "robot": _robot,
445 "format": _format,
446 "more": _more,
447 "close": _close,
448}
451def make_icon(name: str, color: QColor | str) -> QIcon:
452 """An icon by name (see ``DRAWERS``) in the given color; Qt derives the
453 greyed-out disabled look from it."""
454 color = QColor(color)
455 icon = QIcon()
456 for ratio in (1, 2, 3):
457 pixmap, painter = _canvas(ratio)
458 DRAWERS[name](painter, color)
459 painter.end()
460 icon.addPixmap(pixmap)
461 return icon
464# ------------------------------------------------------------ folder badges
465def _badge_code(p: QPainter, c: QColor):
466 p.setPen(_pen(c, 2.6))
467 p.drawPolyline([QPointF(8.5, 7), QPointF(4, 12), QPointF(8.5, 17)])
468 p.drawPolyline([QPointF(15.5, 7), QPointF(20, 12), QPointF(15.5, 17)])
469 p.drawLine(QPointF(13.6, 5.5), QPointF(10.4, 18.5))
472def _badge_bulb(p: QPainter, c: QColor):
473 p.setPen(Qt.NoPen)
474 p.setBrush(c)
475 p.drawEllipse(QPointF(12, 9.5), 6, 6)
476 p.drawRoundedRect(QRectF(9, 14, 6, 4.5), 1, 1)
477 p.drawRoundedRect(QRectF(9.8, 19.2, 4.4, 2), 1, 1)
480def _badge_hash(p: QPainter, c: QColor):
481 p.setPen(_pen(c, 2.4))
482 p.drawLine(QPointF(9.5, 4.5), QPointF(8, 19.5))
483 p.drawLine(QPointF(16, 4.5), QPointF(14.5, 19.5))
484 p.drawLine(QPointF(5, 9.5), QPointF(19.5, 9.5))
485 p.drawLine(QPointF(4.5, 14.5), QPointF(19, 14.5))
488def _badge_cube(p: QPainter, c: QColor):
489 p.setPen(_pen(c, 2.0))
490 p.setBrush(Qt.NoBrush)
491 top = [QPointF(12, 3.5), QPointF(20, 7.5), QPointF(12, 11.5), QPointF(4, 7.5), QPointF(12, 3.5)]
492 p.drawPolyline(top)
493 p.drawPolyline([QPointF(4, 7.5), QPointF(4, 16.5), QPointF(12, 20.5), QPointF(20, 16.5),
494 QPointF(20, 7.5)])
495 p.drawLine(QPointF(12, 11.5), QPointF(12, 20.5))
498BADGES = {
499 "code": _badge_code,
500 "run": _play,
501 "test": _flask,
502 "example": _badge_bulb,
503 "include": _badge_hash,
504 "manifest": _badge_cube,
505}
506# Badge background colors by fpm folder role.
507BADGE_COLORS = {
508 "code": "#1c7ed6", "run": "#2f9e44", "test": "#7048e8", "example": "#e8590c",
509 "include": "#868e96", "manifest": "#e8590c",
510}
513def badged_icon(base: QIcon, badge: str, sizes=(16, 20, 32)) -> QIcon:
514 """``base`` (e.g. the system folder icon) with a small colored badge in
515 its lower-right corner showing ``badge`` (see ``BADGES``)."""
516 color = QColor(BADGE_COLORS.get(badge, "#868e96"))
517 icon = QIcon()
518 for size in sizes:
519 for ratio in (1, 2):
520 pixmap = QPixmap(int(size * ratio), int(size * ratio))
521 pixmap.setDevicePixelRatio(ratio)
522 pixmap.fill(Qt.transparent)
523 painter = QPainter(pixmap)
524 painter.setRenderHint(QPainter.Antialiasing)
525 base.paint(painter, 0, 0, size, size)
526 d = size * 0.62 # badge diameter
527 x, y = size - d, size - d
528 painter.setPen(QPen(QColor(255, 255, 255, 230), max(1.0, size / 16)))
529 painter.setBrush(color)
530 painter.drawEllipse(QRectF(x, y, d, d))
531 painter.save()
532 painter.translate(x + d * 0.18, y + d * 0.18)
533 painter.scale(d * 0.64 / SIZE, d * 0.64 / SIZE)
534 BADGES[badge](painter, QColor("white"))
535 painter.restore()
536 painter.end()
537 icon.addPixmap(pixmap)
538 return icon
542# ----------------------------------------------------------------- app icon
543LOGO_PATH = Path(__file__).with_name("resources") / "fpm-logo-color.svg"
544FORTRAN_FILE_PATH = Path(__file__).with_name("resources") / "fortran-file.svg"
545# Fortran sources (any case, e.g. .F90): shown with the Fortran file icon.
546FORTRAN_SUFFIXES = {".f", ".for", ".ftn", ".f77", ".f90", ".f95", ".f03", ".f08", ".f18", ".f23",
547 ".fpp", ".fypp"}
548# Fortran include files: the same icon in gray.
549FORTRAN_INCLUDE_SUFFIXES = {".inc", ".if"}
550_fortran_icons: dict[bool, QIcon] = {}
553def fortran_file_icon(include: bool = False) -> QIcon:
554 """The icon for Fortran source files (a page with the Fortran "F"), or
555 in gray for include files, drawn crisply at each size."""
556 icon = _fortran_icons.get(include)
557 if icon is None:
558 from qtpy.QtCore import QByteArray
559 from qtpy.QtSvg import QSvgRenderer
561 svg = FORTRAN_FILE_PATH.read_text(encoding="utf-8")
562 if include:
563 svg = svg.replace("#734f96", "#6c757d").replace("#a98cc9", "#adb5bd")
564 renderer = QSvgRenderer(QByteArray(svg.encode()))
565 icon = _fortran_icons[include] = QIcon()
566 if renderer.isValid(): 566 ↛ 575line 566 didn't jump to line 575 because the condition on line 566 was always true
567 for size in (16, 20, 24, 32, 40, 48, 64):
568 pixmap = QPixmap(size, size)
569 pixmap.fill(Qt.transparent)
570 painter = QPainter(pixmap)
571 painter.setRenderHint(QPainter.Antialiasing)
572 renderer.render(painter)
573 painter.end()
574 icon.addPixmap(pixmap)
575 return icon
578def app_icon() -> QIcon:
579 """The application icon: the fpm logo centred on a white rounded square
580 (the usual macOS app-icon shape), drawn crisply at each size."""
581 from qtpy.QtSvg import QSvgRenderer
583 renderer = QSvgRenderer(str(LOGO_PATH))
584 icon = QIcon()
585 if not renderer.isValid(): 585 ↛ 586line 585 didn't jump to line 586 because the condition on line 585 was never true
586 return icon
587 for size in (16, 32, 48, 64, 128, 256, 512, 1024):
588 pixmap = QPixmap(size, size)
589 pixmap.fill(Qt.transparent)
590 p = QPainter(pixmap)
591 draw_app_icon(p, size, renderer)
592 p.end()
593 icon.addPixmap(pixmap)
594 return icon
597def draw_app_icon(p: QPainter, size: float, renderer):
598 """Paint the app icon (`app_icon`) at (0, 0), ``size`` across, with the
599 logo from ``renderer`` (a QSvgRenderer)."""
600 p.setRenderHint(QPainter.Antialiasing)
601 p.setRenderHint(QPainter.SmoothPixmapTransform)
602 view = renderer.viewBoxF()
603 aspect = view.width() / view.height() if view.height() else 1.0
604 # macOS icons leave ~10% margin around the rounded square.
605 margin = size * 0.09
606 tile = QRectF(margin, margin, size - 2 * margin, size - 2 * margin)
607 radius = tile.width() * 0.225
608 if size >= 32: # a soft shadow (too muddy at tiny sizes)
609 for i, alpha in enumerate((18, 12, 6)):
610 grow = size * 0.006 * (i + 1)
611 p.setPen(Qt.NoPen)
612 p.setBrush(QColor(0, 0, 0, alpha))
613 p.drawRoundedRect(tile.adjusted(-grow, -grow + size * 0.012, grow, grow + size * 0.012),
614 radius + grow, radius + grow)
615 gradient = QLinearGradient(tile.topLeft(), tile.bottomLeft())
616 gradient.setColorAt(0, QColor("#ffffff"))
617 gradient.setColorAt(1, QColor("#eef0f6"))
618 p.setBrush(gradient)
619 p.setPen(QPen(QColor(0, 0, 0, 28), max(1.0, size / 256)))
620 p.drawRoundedRect(tile, radius, radius)
621 # The logo, as large as fits with some breathing room.
622 box = tile.adjusted(tile.width() * 0.16, tile.height() * 0.12,
623 -tile.width() * 0.16, -tile.height() * 0.12)
624 h = box.height()
625 w = h * aspect
626 if w > box.width(): 626 ↛ 627line 626 didn't jump to line 627 because the condition on line 626 was never true
627 w = box.width()
628 h = w / aspect
629 renderer.render(p, QRectF(box.center().x() - w / 2, box.center().y() - h / 2, w, h))
632@cache
633def _logo_text() -> str:
634 return LOGO_PATH.read_text()
637def recolored_logo(turn: float) -> bytes:
638 """The fpm logo's SVG with each piece's color moved ``turn`` of the way
639 around the color wheel (0 to 1), and a little more or less for each piece
640 (so the colors ripple as ``turn`` grows), keeping their depth. At 0 (or
641 1) it's the logo's own colors."""
642 svg = _logo_text()
643 pieces = iter(range(100))
645 def shift(match: re.Match) -> str:
646 color = QColor(match.group(1))
647 h, s, v, _ = color.getHsvF()
648 phase = 2 * math.pi * next(pieces) / 5
649 ripple = 0.04 * (math.sin(4 * math.pi * turn + phase) - math.sin(phase)) # (0 at turn 0 and 1)
650 color.setHsvF((max(h, 0) + turn + ripple) % 1.0, s, v)
651 return f"fill:{color.name()}"
653 return re.sub(r"fill:(#[0-9a-fA-F]{6})", shift, svg).encode()
656# ------------------------------------------------------------ symbol kinds
657# Outline icons: a colored badge with a letter for each kind of Fortran
658# symbol (like the symbol icons of other editors).
659SYMBOL_BADGES = {
660 "module": ("M", "#8e44ad"),
661 "submodule": ("M", "#b15dcc"),
662 "program": ("P", "#5f3dc4"),
663 "subroutine": ("S", "#1c7ed6"),
664 "function": ("F", "#e8590c"),
665 "type": ("T", "#0ca678"),
666 "interface": ("I", "#2f9e44"),
667 "enum": ("E", "#f08c00"),
668 "constructor": ("C", "#c2255c"),
669}
670SYMBOL_INDENT = 12 # logical pixels per nesting level in lists of symbols
671_symbol_icons: dict = {}
674def symbol_icon(kind: str) -> QIcon:
675 """A 16 px badge for a kind of symbol ("function", "module"…)."""
676 if kind in _symbol_icons:
677 return _symbol_icons[kind]
678 letter, color = SYMBOL_BADGES.get(kind, ((kind[:1] or "?").upper(), "#868e96"))
679 width, height = 16, 16
680 icon = QIcon()
681 for ratio in (1, 2, 3):
682 pixmap = QPixmap(int(width * ratio), int(height * ratio))
683 pixmap.setDevicePixelRatio(ratio)
684 pixmap.fill(Qt.transparent)
685 p = QPainter(pixmap)
686 p.setRenderHint(QPainter.Antialiasing)
687 p.setRenderHint(QPainter.TextAntialiasing)
688 box = QRectF(1, 1, 14, 14)
689 p.setPen(Qt.NoPen)
690 p.setBrush(QColor(color))
691 p.drawRoundedRect(box, 3.5, 3.5)
692 font = p.font()
693 font.setPixelSize(10)
694 font.setBold(True)
695 p.setFont(font)
696 p.setPen(QColor("white"))
697 p.drawText(box, Qt.AlignCenter, letter)
698 p.end()
699 icon.addPixmap(pixmap)
700 _symbol_icons[kind] = icon
701 return icon