"""Render ANSI/SGR-coloured terminal text (e.g. wttr.in) into a Gtk.TextView. wttr.in returns real terminal escape sequences when curled. We parse the SGR subset it uses (basic 8/16 colours, xterm-256, truecolor, bold, reset) and emit Gtk.TextTags so the CLI art keeps its colours and block-glyph alignment. This is deliberately reusable by any future "show me some CLI art" widget. """ from __future__ import annotations import re import gi gi.require_version("Gtk", "4.0") from gi.repository import Gdk, Gtk # noqa: E402 _SGR = re.compile(r"\x1b\[([0-9;]*)m") # xterm 16-colour base palette (approx, tuned to the CyberQueer dark background). _BASE16 = [ (0x00, 0x00, 0x00), (0xCC, 0x24, 0x24), (0x33, 0xCC, 0x33), (0xCC, 0xCC, 0x33), (0x33, 0x66, 0xCC), (0xCC, 0x33, 0xCC), (0x33, 0xCC, 0xCC), (0xD6, 0xAB, 0xAB), (0x66, 0x66, 0x66), (0xF5, 0x05, 0x05), (0x55, 0xFF, 0x55), (0xFF, 0xFF, 0x55), (0x55, 0x88, 0xFF), (0xE4, 0x00, 0x46), (0x55, 0xFF, 0xFF), (0xFF, 0xFF, 0xFF), ] def _xterm256(n: int) -> tuple[int, int, int]: if n < 16: return _BASE16[n] if n < 232: # 6x6x6 colour cube n -= 16 r, g, b = n // 36, (n // 6) % 6, n % 6 conv = lambda c: 55 + c * 40 if c else 0 return conv(r), conv(g), conv(b) v = 8 + (n - 232) * 10 # grayscale ramp return v, v, v def _rgba(rgb: tuple[int, int, int]) -> Gdk.RGBA: c = Gdk.RGBA() c.red, c.green, c.blue, c.alpha = rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1.0 return c class AnsiRenderer: """Owns a TextView and repaints it from ANSI text. Tags are cached by state.""" def __init__(self) -> None: self.view = Gtk.TextView( editable=False, cursor_visible=False, monospace=True, wrap_mode=Gtk.WrapMode.NONE, ) self.view.add_css_class("ansi-view") self.buffer = self.view.get_buffer() self._tag_cache: dict[tuple, Gtk.TextTag] = {} def _tag(self, fg, bg, bold) -> Gtk.TextTag | None: if fg is None and bg is None and not bold: return None key = (fg, bg, bold) tag = self._tag_cache.get(key) if tag is None: tag = self.buffer.create_tag() if fg is not None: tag.set_property("foreground-rgba", _rgba(fg)) if bg is not None: tag.set_property("background-rgba", _rgba(bg)) if bold: tag.set_property("weight", 700) self._tag_cache[key] = tag return tag def set_text(self, text: str) -> None: self.buffer.set_text("", 0) fg = bg = None bold = False pos = 0 for m in _SGR.finditer(text): chunk = text[pos:m.start()] if chunk: self._insert(chunk, fg, bg, bold) fg, bg, bold = self._apply(m.group(1), fg, bg, bold) pos = m.end() tail = text[pos:] if tail: self._insert(tail, fg, bg, bold) def _insert(self, chunk, fg, bg, bold) -> None: end = self.buffer.get_end_iter() tag = self._tag(fg, bg, bold) if tag is None: self.buffer.insert(end, chunk) else: self.buffer.insert_with_tags(end, chunk, tag) @staticmethod def _apply(params: str, fg, bg, bold): codes = [int(x) if x else 0 for x in params.split(";")] if params else [0] i = 0 while i < len(codes): c = codes[i] if c == 0: fg = bg = None bold = False elif c == 1: bold = True elif c == 22: bold = False elif c == 39: fg = None elif c == 49: bg = None elif 30 <= c <= 37: fg = _BASE16[c - 30] elif 90 <= c <= 97: fg = _BASE16[c - 90 + 8] elif 40 <= c <= 47: bg = _BASE16[c - 40] elif 100 <= c <= 107: bg = _BASE16[c - 100 + 8] elif c in (38, 48): target = "fg" if c == 38 else "bg" if i + 1 < len(codes) and codes[i + 1] == 5: val = _xterm256(codes[i + 2]) if i + 2 < len(codes) else None i += 2 elif i + 1 < len(codes) and codes[i + 1] == 2: val = tuple(codes[i + 2:i + 5]) if i + 4 < len(codes) else None i += 4 else: val = None if target == "fg": fg = val else: bg = val i += 1 return fg, bg, bold