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.

AnsiWriter(edit)
59    def __init__(self, edit):
60        self.edit = edit
61        self.reset()
edit
def reset(self):
63    def reset(self):
64        self.fg: QColor | None = None
65        self.bg: QColor | None = None
66        self.bold = self.italic = self.underline = False
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).