""" Shared display rendering utilities. Provides common functionality for displays that render text to images (Pygame, Sixel, Kitty displays). """ from typing import Any 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), } def parse_ansi( text: str, ) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]: """Parse ANSI escape sequences into text tokens with colors. Args: text: Text containing ANSI escape sequences Returns: List of (text, fg_rgb, bg_rgb, bold) tuples """ tokens = [] current_text = "" fg = (204, 204, 204) bg = (0, 0, 0) bold = False i = 0 ANSI_COLORS_4BIT = { 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 == "22": bold = False elif c == "39": fg = (204, 204, 204) elif c == "49": bg = (0, 0, 0) elif c.isdigit(): color_idx = int(c) if color_idx in ANSI_COLORS_4BIT: fg = ANSI_COLORS_4BIT[color_idx] elif 30 <= color_idx <= 37: fg = ANSI_COLORS_4BIT.get(color_idx - 30, fg) elif 40 <= color_idx <= 47: bg = ANSI_COLORS_4BIT.get(color_idx - 40, bg) elif 90 <= color_idx <= 97: fg = ANSI_COLORS_4BIT.get(color_idx - 90 + 8, fg) elif 100 <= color_idx <= 107: bg = ANSI_COLORS_4BIT.get(color_idx - 100 + 8, bg) elif c.startswith("38;5;"): idx = int(c.split(";")[-1]) if idx < 256: if idx < 16: fg = ANSI_COLORS_4BIT.get(idx, fg) elif idx < 232: c_idx = idx - 16 fg = ( (c_idx >> 4) * 51, ((c_idx >> 2) & 7) * 51, (c_idx & 3) * 85, ) else: gray = (idx - 232) * 10 + 8 fg = (gray, gray, gray) elif c.startswith("48;5;"): idx = int(c.split(";")[-1]) if idx < 256: if idx < 16: bg = ANSI_COLORS_4BIT.get(idx, bg) elif idx < 232: c_idx = idx - 16 bg = ( (c_idx >> 4) * 51, ((c_idx >> 2) & 7) * 51, (c_idx & 3) * 85, ) else: gray = (idx - 232) * 10 + 8 bg = (gray, gray, gray) i += 1 else: current_text += char i += 1 if current_text: tokens.append((current_text, fg, bg, bold)) return tokens if tokens else [("", fg, bg, bold)] def get_default_font_path() -> str | None: """Get the path to a default monospace font.""" import os import sys from pathlib import Path 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) if "mono" in name or "courier" in name or "terminal" 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: return found if sys.platform != "win32": try: import subprocess for pattern in ["monospace", "DejaVuSansMono", "LiberationMono"]: result = subprocess.run( ["fc-match", "-f", "%{file}", pattern], capture_output=True, text=True, timeout=5, ) if result.returncode == 0 and result.stdout.strip(): font_file = result.stdout.strip() if os.path.exists(font_file): return font_file except Exception: pass return None def render_to_pil( buffer: list[str], width: int, height: int, cell_width: int = 10, cell_height: int = 18, font_path: str | None = None, ) -> Any: """Render buffer to a PIL Image. Args: buffer: List of text lines to render width: Terminal width in characters height: Terminal height in rows cell_width: Width of each character cell in pixels cell_height: Height of each character cell in pixels font_path: Path to TTF/OTF font file (optional) Returns: PIL Image object """ from PIL import Image, ImageDraw, ImageFont img_width = width * cell_width img_height = height * cell_height img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255)) draw = ImageDraw.Draw(img) if font_path: try: font = ImageFont.truetype(font_path, cell_height - 2) except Exception: font = ImageFont.load_default() else: font = ImageFont.load_default() for row_idx, line in enumerate(buffer[:height]): if row_idx >= height: break tokens = parse_ansi(line) x_pos = 0 y_pos = row_idx * 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)) draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font) if font: x_pos += draw.textlength(text, font=font) return img