""" Kitty graphics display backend - renders using kitty's graphics protocol. """ import time def _parse_ansi( text: str, ) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]: """Parse ANSI text into tokens with fg/bg colors. Returns list of (text, fg_rgb, bg_rgb, bold). """ tokens = [] current_text = "" fg = (204, 204, 204) bg = (0, 0, 0) bold = False i = 0 ANSI_COLORS = { 0: (0, 0, 0), 1: (205, 49, 49), 2: (13, 188, 121), 3: (229, 229, 16), 4: (36, 114, 200), 5: (188, 63, 188), 6: (17, 168, 205), 7: (229, 229, 229), 8: (102, 102, 102), 9: (241, 76, 76), 10: (35, 209, 139), 11: (245, 245, 67), 12: (59, 142, 234), 13: (214, 112, 214), 14: (41, 184, 219), 15: (255, 255, 255), } while i < len(text): char = text[i] if char == "\x1b" and i + 1 < len(text) and text[i + 1] == "[": if current_text: tokens.append((current_text, fg, bg, bold)) current_text = "" i += 2 code = "" while i < len(text): c = text[i] if c.isalpha(): break code += c i += 1 if code: codes = code.split(";") for c in codes: if c == "0": fg = (204, 204, 204) bg = (0, 0, 0) bold = False elif c == "1": bold = True elif c.isdigit(): color_idx = int(c) if color_idx in ANSI_COLORS: fg = ANSI_COLORS[color_idx] elif c.startswith("38;5;"): idx = int(c.split(";")[-1]) if idx < 256: fg = ( (idx >> 5) * 51, ((idx >> 2) & 7) * 51, (idx & 3) * 85, ) elif c.startswith("48;5;"): idx = int(c.split(";")[-1]) if idx < 256: bg = ( (idx >> 5) * 51, ((idx >> 2) & 7) * 51, (idx & 3) * 85, ) i += 1 else: current_text += char i += 1 if current_text: tokens.append((current_text, fg, bg, bold)) return tokens def _encode_kitty_graphic(image_data: bytes, width: int, height: int) -> bytes: """Encode image data using kitty's graphics protocol.""" import base64 encoded = base64.b64encode(image_data).decode("ascii") chunks = [] for i in range(0, len(encoded), 4096): chunk = encoded[i : i + 4096] if i == 0: chunks.append(f"\x1b_Gf=100,t=d,s={width},v={height},c=1,r=1;{chunk}\x1b\\") else: chunks.append(f"\x1b_Gm={height};{chunk}\x1b\\") return "".join(chunks).encode("utf-8") class KittyDisplay: """Kitty graphics display backend using kitty's native protocol.""" width: int = 80 height: int = 24 def __init__(self, cell_width: int = 9, cell_height: int = 16): self.width = 80 self.height = 24 self.cell_width = cell_width self.cell_height = cell_height self._initialized = False self._font_path = None def init(self, width: int, height: int) -> None: self.width = width self.height = height self._initialized = True def _get_font_path(self) -> str | None: """Get font path from env or detect common locations.""" import os import sys from pathlib import Path if self._font_path: return self._font_path env_font = os.environ.get("MAINLINE_KITTY_FONT") if env_font and os.path.exists(env_font): self._font_path = env_font return env_font def search_dir(base_path: str) -> str | None: if not os.path.exists(base_path): return None if os.path.isfile(base_path): return base_path for font_file in Path(base_path).rglob("*"): if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"): name = font_file.stem.lower() if "geist" in name and ("nerd" in name or "mono" in name): return str(font_file) return None search_dirs = [] if sys.platform == "darwin": search_dirs.extend( [os.path.expanduser("~/Library/Fonts/"), "/System/Library/Fonts/"] ) elif sys.platform == "win32": search_dirs.extend( [ os.path.expanduser( "~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\" ), "C:\\Windows\\Fonts\\", ] ) else: search_dirs.extend( [ os.path.expanduser("~/.local/share/fonts/"), os.path.expanduser("~/.fonts/"), "/usr/share/fonts/", ] ) for search_dir_path in search_dirs: found = search_dir(search_dir_path) if found: self._font_path = found return found return None def show(self, buffer: list[str]) -> None: import sys t0 = time.perf_counter() img_width = self.width * self.cell_width img_height = self.height * self.cell_height try: from PIL import Image, ImageDraw, ImageFont except ImportError: return img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255)) draw = ImageDraw.Draw(img) font_path = self._get_font_path() font = None if font_path: try: font = ImageFont.truetype(font_path, self.cell_height - 2) except Exception: font = None if font is None: try: font = ImageFont.load_default() except Exception: font = None for row_idx, line in enumerate(buffer[: self.height]): if row_idx >= self.height: break tokens = _parse_ansi(line) x_pos = 0 y_pos = row_idx * self.cell_height for text, fg, bg, bold in tokens: if not text: continue if bg != (0, 0, 0): bbox = draw.textbbox((x_pos, y_pos), text, font=font) draw.rectangle(bbox, fill=(*bg, 255)) if bold and font: draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font) draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font) if font: x_pos += draw.textlength(text, font=font) from io import BytesIO output = BytesIO() img.save(output, format="PNG") png_data = output.getvalue() graphic = _encode_kitty_graphic(png_data, img_width, img_height) sys.stdout.buffer.write(graphic) sys.stdout.flush() elapsed_ms = (time.perf_counter() - t0) * 1000 from engine.display import get_monitor monitor = get_monitor() if monitor: chars_in = sum(len(line) for line in buffer) monitor.record_effect("kitty_display", elapsed_ms, chars_in, chars_in) def clear(self) -> None: import sys sys.stdout.buffer.write(b"\x1b_Ga=d\x1b\\") sys.stdout.flush() def cleanup(self) -> None: self.clear()