139 lines
4.6 KiB
Python
139 lines
4.6 KiB
Python
"""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
|