Coverage for vibefpmgui/gremlins.py: 100%

69 statements  

« 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. 

2 

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. 

8 

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""" 

13 

14from __future__ import annotations 

15 

16import re 

17import unicodedata 

18from typing import NamedTuple 

19 

20from qtpy.QtCore import QPointF, QRectF, Qt 

21from qtpy.QtGui import QColor, QPainter, QPainterPath, QPen 

22 

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 

26 

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"} 

32 

33 

34class Gremlin(NamedTuple): 

35 line: int # 1-based 

36 column: int # 0-based 

37 char: str 

38 

39 @property 

40 def code(self) -> str: 

41 return f"U+{ord(self.char):04X}" 

42 

43 @property 

44 def message(self) -> str: 

45 return describe(self.char) 

46 

47 

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)" 

58 

59 

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 

75 

76 

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()