Coverage for vibefpmgui/ansi.py: 94%
100 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"""Terminal color codes (ANSI escape sequences) in program output.
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"""
9from __future__ import annotations
11import re
13from qtpy.QtGui import QColor, QFont, QPalette, QTextCharFormat, QTextCursor
14from qtpy.QtWidgets import QApplication
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;?]*)?$")
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"]
31def strip(text: str) -> str:
32 """``text`` without any escape sequences."""
33 return ANSI_RE.sub("", text)
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, "")
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)
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."""
58 def __init__(self, edit):
59 self.edit = edit
60 self.reset()
62 def reset(self):
63 self.fg: QColor | None = None
64 self.bg: QColor | None = None
65 self.bold = self.italic = self.underline = False
67 def _colors(self) -> list[str]:
68 base = QApplication.palette().color(QPalette.Base)
69 return DARK if base.lightness() < 128 else LIGHT
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
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
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()