Dotfiles/desktopenvs/hyprdrive/astro-menu/lib/ansi.py

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