Coverage for vibefpmgui/ansi.py: 94%

100 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""Terminal color codes (ANSI escape sequences) in program output. 

2 

3Some compilers color their messages even when writing to a file or pipe 

4(lfortran always does, e.g. ``\\x1b[0;31;1msemantic error\\x1b[0;0m``). 

5`AnsiWriter` shows those colors in a text widget; `strip` 

6removes them for parsing. 

7""" 

8 

9from __future__ import annotations 

10 

11import re 

12 

13from qtpy.QtGui import QColor, QFont, QPalette, QTextCharFormat, QTextCursor 

14from qtpy.QtWidgets import QApplication 

15 

16# Every escape sequence: CSI ("\x1b[...X") and OSC ("\x1b]...\x07"). 

17ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07") 

18# Color/style (SGR) sequences, the only ones that are shown. 

19SGR_RE = re.compile(r"\x1b\[([0-9;]*)m") 

20# The start of a sequence cut off at the end of a chunk of output. 

21PARTIAL_RE = re.compile(r"\x1b(?:\[[0-9;?]*)?$") 

22 

23# The 16 standard colors (black, red, green, yellow, blue, magenta, cyan, 

24# white, then the bright versions), chosen to read well on each background. 

25LIGHT = ["#000000", "#c92a2a", "#2b8a3e", "#a86b00", "#1c5fd4", "#a61e9e", "#0b7285", "#868e96", 

26 "#495057", "#e03131", "#37b24d", "#c78a00", "#3b7ddd", "#be4bdb", "#1098ad", "#adb5bd"] 

27DARK = ["#5c5f66", "#ff6b6b", "#69db7c", "#ffd43b", "#74a8ff", "#e599f7", "#66d9e8", "#ced4da", 

28 "#868e96", "#ff8787", "#8ce99a", "#ffe066", "#a5c8ff", "#eebefa", "#99e9f2", "#ffffff"] 

29 

30 

31def strip(text: str) -> str: 

32 """``text`` without any escape sequences.""" 

33 return ANSI_RE.sub("", text) 

34 

35 

36def split_partial(text: str) -> tuple[str, str]: 

37 """(complete text, unfinished escape sequence at its end) — the rest of 

38 the sequence arrives with the next chunk of output.""" 

39 match = PARTIAL_RE.search(text) 

40 return (text[:match.start()], text[match.start():]) if match else (text, "") 

41 

42 

43def _xterm_256(n: int, colors: list[str]) -> QColor: 

44 if n < 16: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 return QColor(colors[n]) 

46 if n < 232: # 6x6x6 color cube 46 ↛ 50line 46 didn't jump to line 50 because the condition on line 46 was always true

47 n -= 16 

48 steps = [0, 95, 135, 175, 215, 255] 

49 return QColor(steps[n // 36], steps[n // 6 % 6], steps[n % 6]) 

50 grey = 8 + (n - 232) * 10 

51 return QColor(grey, grey, grey) 

52 

53 

54class AnsiWriter: 

55 """Appends text to a QPlainTextEdit, turning color codes into formatting. 

56 The current color carries over between calls, like in a terminal.""" 

57 

58 def __init__(self, edit): 

59 self.edit = edit 

60 self.reset() 

61 

62 def reset(self): 

63 self.fg: QColor | None = None 

64 self.bg: QColor | None = None 

65 self.bold = self.italic = self.underline = False 

66 

67 def _colors(self) -> list[str]: 

68 base = QApplication.palette().color(QPalette.Base) 

69 return DARK if base.lightness() < 128 else LIGHT 

70 

71 def _apply(self, params: str): 

72 codes = [int(c) if c else 0 for c in params.split(";")] if params else [0] 

73 colors = self._colors() 

74 i = 0 

75 while i < len(codes): 

76 c = codes[i] 

77 if c == 0: 

78 self.reset() 

79 elif c == 1: 

80 self.bold = True 

81 elif c == 3: 

82 self.italic = True 

83 elif c == 4: 

84 self.underline = True 

85 elif c == 22: 

86 self.bold = False 

87 elif c == 23: 

88 self.italic = False 

89 elif c == 24: 

90 self.underline = False 

91 elif 30 <= c <= 37 or 90 <= c <= 97: 

92 self.fg = QColor(colors[c - 30 if c < 90 else c - 90 + 8]) 

93 elif 40 <= c <= 47 or 100 <= c <= 107: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true

94 self.bg = QColor(colors[c - 40 if c < 100 else c - 100 + 8]) 

95 elif c == 39: 

96 self.fg = None 

97 elif c == 49: 

98 self.bg = None 

99 elif c in (38, 48) and i + 1 < len(codes): 99 ↛ 109line 99 didn't jump to line 109 because the condition on line 99 was always true

100 color = None 

101 if codes[i + 1] == 5 and i + 2 < len(codes): 

102 color, i = _xterm_256(codes[i + 2], colors), i + 2 

103 elif codes[i + 1] == 2 and i + 4 < len(codes): 103 ↛ 105line 103 didn't jump to line 105 because the condition on line 103 was always true

104 color, i = QColor(*(max(0, min(255, v)) for v in codes[i + 2:i + 5])), i + 4 

105 if c == 38: 

106 self.fg = color 

107 else: 

108 self.bg = color 

109 i += 1 

110 

111 def _format(self, bold: bool) -> QTextCharFormat: 

112 fmt = QTextCharFormat() 

113 fmt.setFontWeight(QFont.Bold if bold or self.bold else QFont.Normal) 

114 fmt.setFontItalic(self.italic) 

115 fmt.setFontUnderline(self.underline) 

116 if self.fg is not None: 

117 fmt.setForeground(self.fg) 

118 if self.bg is not None: 

119 fmt.setBackground(self.bg) 

120 return fmt 

121 

122 def write(self, text: str, bold: bool = False, ansi: bool = True): 

123 """Append ``text``; with ``ansi`` False it is shown as is, in the 

124 default color (for the app's own messages).""" 

125 cursor = self.edit.textCursor() 

126 cursor.movePosition(QTextCursor.End) 

127 if not ansi: 

128 saved = (self.fg, self.bg, self.bold, self.italic, self.underline) 

129 self.reset() 

130 cursor.insertText(text, self._format(bold)) 

131 self.fg, self.bg, self.bold, self.italic, self.underline = saved 

132 else: 

133 pos = 0 

134 for match in SGR_RE.finditer(text): 

135 if match.start() > pos: 

136 cursor.insertText(strip(text[pos:match.start()]), self._format(bold)) 

137 self._apply(match.group(1)) 

138 pos = match.end() 

139 if pos < len(text): 

140 cursor.insertText(strip(text[pos:]), self._format(bold)) 

141 self.edit.setTextCursor(cursor) 

142 self.edit.ensureCursorVisible()