vibefpmgui.ansi
Terminal color codes (ANSI escape sequences) in program output.
Some compilers color their messages even when writing to a file or pipe
(lfortran always does, e.g. \x1b[0;31;1msemantic error\x1b[0;0m).
AnsiWriter shows those colors in a text widget; strip
removes them for parsing.
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: 45 return QColor(colors[n]) 46 if n < 232: # 6x6x6 color cube 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: 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): 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): 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()
ANSI_RE =
re.compile('\\x1b\\[[0-9;?]*[A-Za-z]|\\x1b\\][^\\x07]*\\x07')
SGR_RE =
re.compile('\\x1b\\[([0-9;]*)m')
PARTIAL_RE =
re.compile('\\x1b(?:\\[[0-9;?]*)?$')
LIGHT =
['#000000', '#c92a2a', '#2b8a3e', '#a86b00', '#1c5fd4', '#a61e9e', '#0b7285', '#868e96', '#495057', '#e03131', '#37b24d', '#c78a00', '#3b7ddd', '#be4bdb', '#1098ad', '#adb5bd']
DARK =
['#5c5f66', '#ff6b6b', '#69db7c', '#ffd43b', '#74a8ff', '#e599f7', '#66d9e8', '#ced4da', '#868e96', '#ff8787', '#8ce99a', '#ffe066', '#a5c8ff', '#eebefa', '#99e9f2', '#ffffff']
def
strip(text: str) -> str:
32def strip(text: str) -> str: 33 """``text`` without any escape sequences.""" 34 return ANSI_RE.sub("", text)
text without any escape sequences.
def
split_partial(text: str) -> tuple[str, str]:
37def split_partial(text: str) -> tuple[str, str]: 38 """(complete text, unfinished escape sequence at its end) — the rest of 39 the sequence arrives with the next chunk of output.""" 40 match = PARTIAL_RE.search(text) 41 return (text[:match.start()], text[match.start():]) if match else (text, "")
(complete text, unfinished escape sequence at its end) — the rest of the sequence arrives with the next chunk of output.
class
AnsiWriter:
55class AnsiWriter: 56 """Appends text to a QPlainTextEdit, turning color codes into formatting. 57 The current color carries over between calls, like in a terminal.""" 58 59 def __init__(self, edit): 60 self.edit = edit 61 self.reset() 62 63 def reset(self): 64 self.fg: QColor | None = None 65 self.bg: QColor | None = None 66 self.bold = self.italic = self.underline = False 67 68 def _colors(self) -> list[str]: 69 base = QApplication.palette().color(QPalette.Base) 70 return DARK if base.lightness() < 128 else LIGHT 71 72 def _apply(self, params: str): 73 codes = [int(c) if c else 0 for c in params.split(";")] if params else [0] 74 colors = self._colors() 75 i = 0 76 while i < len(codes): 77 c = codes[i] 78 if c == 0: 79 self.reset() 80 elif c == 1: 81 self.bold = True 82 elif c == 3: 83 self.italic = True 84 elif c == 4: 85 self.underline = True 86 elif c == 22: 87 self.bold = False 88 elif c == 23: 89 self.italic = False 90 elif c == 24: 91 self.underline = False 92 elif 30 <= c <= 37 or 90 <= c <= 97: 93 self.fg = QColor(colors[c - 30 if c < 90 else c - 90 + 8]) 94 elif 40 <= c <= 47 or 100 <= c <= 107: 95 self.bg = QColor(colors[c - 40 if c < 100 else c - 100 + 8]) 96 elif c == 39: 97 self.fg = None 98 elif c == 49: 99 self.bg = None 100 elif c in (38, 48) and i + 1 < len(codes): 101 color = None 102 if codes[i + 1] == 5 and i + 2 < len(codes): 103 color, i = _xterm_256(codes[i + 2], colors), i + 2 104 elif codes[i + 1] == 2 and i + 4 < len(codes): 105 color, i = QColor(*(max(0, min(255, v)) for v in codes[i + 2:i + 5])), i + 4 106 if c == 38: 107 self.fg = color 108 else: 109 self.bg = color 110 i += 1 111 112 def _format(self, bold: bool) -> QTextCharFormat: 113 fmt = QTextCharFormat() 114 fmt.setFontWeight(QFont.Bold if bold or self.bold else QFont.Normal) 115 fmt.setFontItalic(self.italic) 116 fmt.setFontUnderline(self.underline) 117 if self.fg is not None: 118 fmt.setForeground(self.fg) 119 if self.bg is not None: 120 fmt.setBackground(self.bg) 121 return fmt 122 123 def write(self, text: str, bold: bool = False, ansi: bool = True): 124 """Append ``text``; with ``ansi`` False it is shown as is, in the 125 default color (for the app's own messages).""" 126 cursor = self.edit.textCursor() 127 cursor.movePosition(QTextCursor.End) 128 if not ansi: 129 saved = (self.fg, self.bg, self.bold, self.italic, self.underline) 130 self.reset() 131 cursor.insertText(text, self._format(bold)) 132 self.fg, self.bg, self.bold, self.italic, self.underline = saved 133 else: 134 pos = 0 135 for match in SGR_RE.finditer(text): 136 if match.start() > pos: 137 cursor.insertText(strip(text[pos:match.start()]), self._format(bold)) 138 self._apply(match.group(1)) 139 pos = match.end() 140 if pos < len(text): 141 cursor.insertText(strip(text[pos:]), self._format(bold)) 142 self.edit.setTextCursor(cursor) 143 self.edit.ensureCursorVisible()
Appends text to a QPlainTextEdit, turning color codes into formatting. The current color carries over between calls, like in a terminal.
def
write(self, text: str, bold: bool = False, ansi: bool = True):
123 def write(self, text: str, bold: bool = False, ansi: bool = True): 124 """Append ``text``; with ``ansi`` False it is shown as is, in the 125 default color (for the app's own messages).""" 126 cursor = self.edit.textCursor() 127 cursor.movePosition(QTextCursor.End) 128 if not ansi: 129 saved = (self.fg, self.bg, self.bold, self.italic, self.underline) 130 self.reset() 131 cursor.insertText(text, self._format(bold)) 132 self.fg, self.bg, self.bold, self.italic, self.underline = saved 133 else: 134 pos = 0 135 for match in SGR_RE.finditer(text): 136 if match.start() > pos: 137 cursor.insertText(strip(text[pos:match.start()]), self._format(bold)) 138 self._apply(match.group(1)) 139 pos = match.end() 140 if pos < len(text): 141 cursor.insertText(strip(text[pos:]), self._format(bold)) 142 self.edit.setTextCursor(cursor) 143 self.edit.ensureCursorVisible()
Append text; with ansi False it is shown as is, in the
default color (for the app's own messages).