vibefpmgui.icons

Small vector icons for the command toolbar, drawn with QPainter so they match the current text color (light or dark mode) at any screen scale.

  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."""
  3
  4from __future__ import annotations
  5
  6import math
  7import re
  8from functools import cache
  9from pathlib import Path
 10
 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)
 22
 23SIZE = 24  # logical size; drawn at several pixel ratios for crisp HiDPI
 24
 25
 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
 33
 34
 35def _pen(color: QColor, width: float = 1.8) -> QPen:
 36    return QPen(color, width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
 37
 38
 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
 49
 50
 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)
 59
 60
 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)
 84
 85
 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()
 98
 99
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)
112
113
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))
128
129
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))
144
145
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
159
160
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)]))
173
174
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))
182
183
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))
194
195
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
212
213
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
227
228
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)
243
244
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)
261
262
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))
268
269
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)
275
276
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)
284
285
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)
291
292
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)
302
303
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)
310
311
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)
318
319
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)
324
325
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))
348
349
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)
363
364
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)])
370
371
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))
377
378
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))
391
392
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)
404
405
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)
416
417
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}
449
450
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
462
463
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))
470
471
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)
478
479
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))
486
487
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))
496
497
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}
511
512
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
539
540
541
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] = {}
551
552
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
560
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():
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
576
577
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
582
583    renderer = QSvgRenderer(str(LOGO_PATH))
584    icon = QIcon()
585    if not renderer.isValid():
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
595
596
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():
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))
630
631
632@cache
633def _logo_text() -> str:
634    return LOGO_PATH.read_text()
635
636
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))
644
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()}"
652
653    return re.sub(r"fill:(#[0-9a-fA-F]{6})", shift, svg).encode()
654
655
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 = {}
672
673
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
SIZE = 24
DRAWERS = {'build': <function _hammer>, 'build-tests': <function _build_tests>, 'run': <function _play>, 'test': <function _flask>, 'coverage': <function _coverage>, 'git': <function _git>, 'complexity': <function _complexity>, 'update': <function _download>, 'clean': <function _broom>, 'clean-all': <function _trash>, 'stop': <function _stop>, 'pause': <function _pause>, 'pencil': <function _pencil>, 'memcheck': <function _memcheck>, 'palette': <function _palette>, 'find-file': <function _find_file>, 'save': <function _save>, 'menu': <function _menu>, 'refresh': <function _refresh>, 'unified': <function _unified>, 'bug': <function _bug>, 'continue': <function _continue>, 'step-over': <function _step_over>, 'step-into': <function _step_into>, 'step-out': <function _step_out>, 'robot': <function _robot>, 'format': <function _format>, 'more': <function _more>, 'close': <function _close>}
def make_icon(name: str, color: PySide6.QtGui.QColor | str) -> PySide6.QtGui.QIcon:
452def make_icon(name: str, color: QColor | str) -> QIcon:
453    """An icon by name (see ``DRAWERS``) in the given color; Qt derives the
454    greyed-out disabled look from it."""
455    color = QColor(color)
456    icon = QIcon()
457    for ratio in (1, 2, 3):
458        pixmap, painter = _canvas(ratio)
459        DRAWERS[name](painter, color)
460        painter.end()
461        icon.addPixmap(pixmap)
462    return icon

An icon by name (see DRAWERS) in the given color; Qt derives the greyed-out disabled look from it.

BADGES = {'code': <function _badge_code>, 'run': <function _play>, 'test': <function _flask>, 'example': <function _badge_bulb>, 'include': <function _badge_hash>, 'manifest': <function _badge_cube>}
BADGE_COLORS = {'code': '#1c7ed6', 'run': '#2f9e44', 'test': '#7048e8', 'example': '#e8590c', 'include': '#868e96', 'manifest': '#e8590c'}
def badged_icon( base: PySide6.QtGui.QIcon, badge: str, sizes=(16, 20, 32)) -> PySide6.QtGui.QIcon:
514def badged_icon(base: QIcon, badge: str, sizes=(16, 20, 32)) -> QIcon:
515    """``base`` (e.g. the system folder icon) with a small colored badge in
516    its lower-right corner showing ``badge`` (see ``BADGES``)."""
517    color = QColor(BADGE_COLORS.get(badge, "#868e96"))
518    icon = QIcon()
519    for size in sizes:
520        for ratio in (1, 2):
521            pixmap = QPixmap(int(size * ratio), int(size * ratio))
522            pixmap.setDevicePixelRatio(ratio)
523            pixmap.fill(Qt.transparent)
524            painter = QPainter(pixmap)
525            painter.setRenderHint(QPainter.Antialiasing)
526            base.paint(painter, 0, 0, size, size)
527            d = size * 0.62  # badge diameter
528            x, y = size - d, size - d
529            painter.setPen(QPen(QColor(255, 255, 255, 230), max(1.0, size / 16)))
530            painter.setBrush(color)
531            painter.drawEllipse(QRectF(x, y, d, d))
532            painter.save()
533            painter.translate(x + d * 0.18, y + d * 0.18)
534            painter.scale(d * 0.64 / SIZE, d * 0.64 / SIZE)
535            BADGES[badge](painter, QColor("white"))
536            painter.restore()
537            painter.end()
538            icon.addPixmap(pixmap)
539    return icon

base (e.g. the system folder icon) with a small colored badge in its lower-right corner showing badge (see BADGES).

LOGO_PATH = PosixPath('/home/runner/work/VibeFPMGUI/VibeFPMGUI/vibefpmgui/resources/fpm-logo-color.svg')
FORTRAN_FILE_PATH = PosixPath('/home/runner/work/VibeFPMGUI/VibeFPMGUI/vibefpmgui/resources/fortran-file.svg')
FORTRAN_SUFFIXES = {'.f95', '.for', '.f90', '.f77', '.f', '.f18', '.fypp', '.f23', '.f08', '.fpp', '.ftn', '.f03'}
FORTRAN_INCLUDE_SUFFIXES = {'.if', '.inc'}
def fortran_file_icon(include: bool = False) -> PySide6.QtGui.QIcon:
554def fortran_file_icon(include: bool = False) -> QIcon:
555    """The icon for Fortran source files (a page with the Fortran "F"), or
556    in gray for include files, drawn crisply at each size."""
557    icon = _fortran_icons.get(include)
558    if icon is None:
559        from qtpy.QtCore import QByteArray
560        from qtpy.QtSvg import QSvgRenderer
561
562        svg = FORTRAN_FILE_PATH.read_text(encoding="utf-8")
563        if include:
564            svg = svg.replace("#734f96", "#6c757d").replace("#a98cc9", "#adb5bd")
565        renderer = QSvgRenderer(QByteArray(svg.encode()))
566        icon = _fortran_icons[include] = QIcon()
567        if renderer.isValid():
568            for size in (16, 20, 24, 32, 40, 48, 64):
569                pixmap = QPixmap(size, size)
570                pixmap.fill(Qt.transparent)
571                painter = QPainter(pixmap)
572                painter.setRenderHint(QPainter.Antialiasing)
573                renderer.render(painter)
574                painter.end()
575                icon.addPixmap(pixmap)
576    return icon

The icon for Fortran source files (a page with the Fortran "F"), or in gray for include files, drawn crisply at each size.

def app_icon() -> PySide6.QtGui.QIcon:
579def app_icon() -> QIcon:
580    """The application icon: the fpm logo centred on a white rounded square
581    (the usual macOS app-icon shape), drawn crisply at each size."""
582    from qtpy.QtSvg import QSvgRenderer
583
584    renderer = QSvgRenderer(str(LOGO_PATH))
585    icon = QIcon()
586    if not renderer.isValid():
587        return icon
588    for size in (16, 32, 48, 64, 128, 256, 512, 1024):
589        pixmap = QPixmap(size, size)
590        pixmap.fill(Qt.transparent)
591        p = QPainter(pixmap)
592        draw_app_icon(p, size, renderer)
593        p.end()
594        icon.addPixmap(pixmap)
595    return icon

The application icon: the fpm logo centred on a white rounded square (the usual macOS app-icon shape), drawn crisply at each size.

def draw_app_icon(p: PySide6.QtGui.QPainter, size: float, renderer):
598def draw_app_icon(p: QPainter, size: float, renderer):
599    """Paint the app icon (`app_icon`) at (0, 0), ``size`` across, with the
600    logo from ``renderer`` (a QSvgRenderer)."""
601    p.setRenderHint(QPainter.Antialiasing)
602    p.setRenderHint(QPainter.SmoothPixmapTransform)
603    view = renderer.viewBoxF()
604    aspect = view.width() / view.height() if view.height() else 1.0
605    # macOS icons leave ~10% margin around the rounded square.
606    margin = size * 0.09
607    tile = QRectF(margin, margin, size - 2 * margin, size - 2 * margin)
608    radius = tile.width() * 0.225
609    if size >= 32:  # a soft shadow (too muddy at tiny sizes)
610        for i, alpha in enumerate((18, 12, 6)):
611            grow = size * 0.006 * (i + 1)
612            p.setPen(Qt.NoPen)
613            p.setBrush(QColor(0, 0, 0, alpha))
614            p.drawRoundedRect(tile.adjusted(-grow, -grow + size * 0.012, grow, grow + size * 0.012),
615                              radius + grow, radius + grow)
616    gradient = QLinearGradient(tile.topLeft(), tile.bottomLeft())
617    gradient.setColorAt(0, QColor("#ffffff"))
618    gradient.setColorAt(1, QColor("#eef0f6"))
619    p.setBrush(gradient)
620    p.setPen(QPen(QColor(0, 0, 0, 28), max(1.0, size / 256)))
621    p.drawRoundedRect(tile, radius, radius)
622    # The logo, as large as fits with some breathing room.
623    box = tile.adjusted(tile.width() * 0.16, tile.height() * 0.12,
624                        -tile.width() * 0.16, -tile.height() * 0.12)
625    h = box.height()
626    w = h * aspect
627    if w > box.width():
628        w = box.width()
629        h = w / aspect
630    renderer.render(p, QRectF(box.center().x() - w / 2, box.center().y() - h / 2, w, h))

Paint the app icon (app_icon) at (0, 0), size across, with the logo from renderer (a QSvgRenderer).

SYMBOL_BADGES = {'module': ('M', '#8e44ad'), 'submodule': ('M', '#b15dcc'), 'program': ('P', '#5f3dc4'), 'subroutine': ('S', '#1c7ed6'), 'function': ('F', '#e8590c'), 'type': ('T', '#0ca678'), 'interface': ('I', '#2f9e44'), 'enum': ('E', '#f08c00'), 'constructor': ('C', '#c2255c')}
SYMBOL_INDENT = 12
def symbol_icon(kind: str) -> PySide6.QtGui.QIcon:
675def symbol_icon(kind: str) -> QIcon:
676    """A 16 px badge for a kind of symbol ("function", "module"…)."""
677    if kind in _symbol_icons:
678        return _symbol_icons[kind]
679    letter, color = SYMBOL_BADGES.get(kind, ((kind[:1] or "?").upper(), "#868e96"))
680    width, height = 16, 16
681    icon = QIcon()
682    for ratio in (1, 2, 3):
683        pixmap = QPixmap(int(width * ratio), int(height * ratio))
684        pixmap.setDevicePixelRatio(ratio)
685        pixmap.fill(Qt.transparent)
686        p = QPainter(pixmap)
687        p.setRenderHint(QPainter.Antialiasing)
688        p.setRenderHint(QPainter.TextAntialiasing)
689        box = QRectF(1, 1, 14, 14)
690        p.setPen(Qt.NoPen)
691        p.setBrush(QColor(color))
692        p.drawRoundedRect(box, 3.5, 3.5)
693        font = p.font()
694        font.setPixelSize(10)
695        font.setBold(True)
696        p.setFont(font)
697        p.setPen(QColor("white"))
698        p.drawText(box, Qt.AlignCenter, letter)
699        p.end()
700        icon.addPixmap(pixmap)
701    _symbol_icons[kind] = icon
702    return icon

A 16 px badge for a kind of symbol ("function", "module"…).