Coverage for vibefpmgui/gremlins.py: 100%
69 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"""*Gremlins*: characters in a file that aren't plain printable ASCII.
3Non-ASCII letters and symbols (``é``, ``°``, smart quotes), invisible ones
4(non-breaking and zero-width spaces, a byte-order mark) and control
5characters. Compilers are often fussy about them (gfortran rejects most
6outside comments and strings), and the invisible ones can't be seen in the
7editor. Tabs and line ends are not gremlins.
9The editor draws a gremlin in the gutter of each line that has one
10(`paint_gremlin`), tints the characters, and lists them in the *Problems*
11tab (View → Show Gremlins turns it off).
12"""
14from __future__ import annotations
16import re
17import unicodedata
18from typing import NamedTuple
20from qtpy.QtCore import QPointF, QRectF, Qt
21from qtpy.QtGui import QColor, QPainter, QPainterPath, QPen
23MAX_GREMLINS = 2000 # per file (a file in another language would have one per accented letter)
24GREMLIN_RE = re.compile(r"[^\t\n\x20-\x7e]")
25COLOR = "#e8590c" # the tint, badge and Problems color
27# Names for the ones that have none, or a long one.
28NAMES = {"\u00a0": "non-breaking space", "\u200b": "zero-width space", "\u200c": "zero-width non-joiner",
29 "\u200d": "zero-width joiner", "\u2060": "word joiner", "\ufeff": "byte-order mark (zero-width)",
30 "\u00ad": "soft hyphen", "\r": "carriage return", "\x0c": "form feed", "\x00": "null",
31 "\x1b": "escape", "\x7f": "delete"}
34class Gremlin(NamedTuple):
35 line: int # 1-based
36 column: int # 0-based
37 char: str
39 @property
40 def code(self) -> str:
41 return f"U+{ord(self.char):04X}"
43 @property
44 def message(self) -> str:
45 return describe(self.char)
48def describe(char: str) -> str:
49 """What a gremlin is, e.g. "non-breaking space, not ASCII" or "latin
50 small letter e with acute (é), not ASCII"."""
51 name = NAMES.get(char)
52 if name is None:
53 if unicodedata.category(char) in ("Cc", "Cf", "Zs", "Zl", "Zp"): # (can't be seen: just the name)
54 name = unicodedata.name(char, "control character").lower()
55 else:
56 return f"{unicodedata.name(char, 'unnamed character').lower()} ({char}), not ASCII"
57 return f"{name}, not ASCII" if ord(char) > 0x7F else f"{name} (a control character)"
60def find(text: str, limit: int = MAX_GREMLINS) -> list[Gremlin]:
61 """Every gremlin in a text ("\\n" line ends), in order."""
62 out: list[Gremlin] = []
63 line, line_start, last = 1, 0, 0
64 for match in GREMLIN_RE.finditer(text):
65 start = match.start()
66 newlines = text.count("\n", last, start)
67 if newlines:
68 line += newlines
69 line_start = text.rfind("\n", 0, start) + 1
70 last = start
71 out.append(Gremlin(line, start - line_start, match.group()))
72 if len(out) >= limit:
73 break
74 return out
77def paint_gremlin(painter: QPainter, rect: QRectF):
78 """A little gremlin (big ears, round eyes, a grin) filling ``rect``."""
79 painter.save()
80 painter.setRenderHint(QPainter.Antialiasing)
81 painter.translate(rect.topLeft())
82 painter.scale(rect.width() / 24, rect.height() / 24) # (drawn on a 24 × 24 grid)
83 green, dark = QColor("#5fb04a"), QColor("#2b5a22")
84 painter.setPen(QPen(dark, 1.2))
85 painter.setBrush(green)
86 for side in (-1, 1): # ears: long and pointed, out to the sides
87 ear = QPainterPath(QPointF(12 + side * 4, 9))
88 ear.lineTo(QPointF(12 + side * 11.5, 4))
89 ear.lineTo(QPointF(12 + side * 6, 14))
90 ear.closeSubpath()
91 painter.drawPath(ear)
92 painter.drawEllipse(QRectF(5, 5.5, 14, 15)) # the head
93 painter.setPen(Qt.NoPen)
94 painter.setBrush(QColor("#fff7d6"))
95 for x in (9, 15):
96 painter.drawEllipse(QPointF(x, 11.5), 2.6, 2.9) # eyes
97 painter.setBrush(QColor("#c92a2a"))
98 for x in (9.5, 14.5):
99 painter.drawEllipse(QPointF(x, 12), 1.2, 1.4) # (red pupils)
100 painter.setPen(QPen(dark, 1.1))
101 painter.setBrush(Qt.NoBrush)
102 grin = QPainterPath(QPointF(8.5, 16))
103 grin.quadTo(QPointF(12, 19), QPointF(15.5, 16))
104 painter.drawPath(grin)
105 painter.restore()