diff --git a/engine/display/backends/kitty.py b/engine/display/backends/kitty.py index 7654f07..e6a5d89 100644 --- a/engine/display/backends/kitty.py +++ b/engine/display/backends/kitty.py @@ -1,98 +1,10 @@ """ -Kitty graphics display backend - renders using kitty's graphics protocol. +Kitty graphics display backend - renders using kitty's native 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 +from engine.display.renderer import get_default_font_path, parse_ansi def _encode_kitty_graphic(image_data: bytes, width: int, height: int) -> bytes: @@ -141,8 +53,6 @@ class KittyDisplay: 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 @@ -152,49 +62,11 @@ class KittyDisplay: 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 + font_path = get_default_font_path() + if font_path: + self._font_path = font_path - 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 + return self._font_path def show(self, buffer: list[str]) -> None: import sys @@ -230,7 +102,7 @@ class KittyDisplay: if row_idx >= self.height: break - tokens = _parse_ansi(line) + tokens = parse_ansi(line) x_pos = 0 y_pos = row_idx * self.cell_height diff --git a/engine/display/backends/pygame.py b/engine/display/backends/pygame.py index 5ebde15..591656e 100644 --- a/engine/display/backends/pygame.py +++ b/engine/display/backends/pygame.py @@ -4,6 +4,8 @@ Pygame display backend - renders to a native application window. import time +from engine.display.renderer import parse_ansi + class PygameDisplay: """Pygame display backend - renders to native window. @@ -141,10 +143,10 @@ class PygameDisplay: if row_idx >= self.height: break - tokens = self._parse_ansi(line) + tokens = parse_ansi(line) x_pos = 0 - for text, fg, bg in tokens: + for text, fg, bg, _bold in tokens: if not text: continue @@ -168,72 +170,6 @@ class PygameDisplay: chars_in = sum(len(line) for line in buffer) monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in) - def _parse_ansi( - self, text: str - ) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int]]]: - """Parse ANSI text into tokens with fg/bg colors.""" - tokens = [] - current_text = "" - fg = (204, 204, 204) - bg = (0, 0, 0) - 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)) - 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) - elif c.isdigit(): - color_idx = int(c) - if color_idx in ANSI_COLORS: - fg = ANSI_COLORS[color_idx] - i += 1 - else: - current_text += char - i += 1 - - if current_text: - tokens.append((current_text, fg, bg)) - - return tokens - def clear(self) -> None: if self._screen and self._pygame: self._screen.fill((0, 0, 0)) diff --git a/engine/display/backends/sixel.py b/engine/display/backends/sixel.py index 6d04776..adc8c7b 100644 --- a/engine/display/backends/sixel.py +++ b/engine/display/backends/sixel.py @@ -4,105 +4,7 @@ Sixel graphics display backend - renders to sixel graphics in terminal. 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: - try: - n = int(c) if c else 0 - except ValueError: - continue - - if n == 0: - fg = (204, 204, 204) - bg = (0, 0, 0) - bold = False - elif n == 1: - bold = True - elif n == 22: - bold = False - elif n == 39: - fg = (204, 204, 204) - elif n == 49: - bg = (0, 0, 0) - elif 30 <= n <= 37: - fg = ANSI_COLORS.get(n - 30 + (8 if bold else 0), fg) - elif 40 <= n <= 47: - bg = ANSI_COLORS.get(n - 40, bg) - elif 90 <= n <= 97: - fg = ANSI_COLORS.get(n - 90 + 8, fg) - elif 100 <= n <= 107: - bg = ANSI_COLORS.get(n - 100 + 8, bg) - elif 1 <= n <= 256: - if n < 16: - fg = ANSI_COLORS.get(n, fg) - elif n < 232: - c = n - 16 - r = (c // 36) * 51 - g = ((c % 36) // 6) * 51 - b = (c % 6) * 51 - fg = (r, g, b) - else: - gray = (n - 232) * 10 + 8 - fg = (gray, gray, gray) - else: - current_text += char - i += 1 - - if current_text: - tokens.append((current_text, fg, bg, bold)) - - return tokens if tokens else [("", fg, bg, bold)] +from engine.display.renderer import get_default_font_path, parse_ansi def _encode_sixel(image) -> str: @@ -191,10 +93,8 @@ class SixelDisplay: self._font_path = None def _get_font_path(self) -> str | None: - """Get font path from env or detect common locations (cross-platform).""" + """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 @@ -204,72 +104,11 @@ class SixelDisplay: self._font_path = env_font return env_font - def search_dir(base_path: str) -> str | None: - """Search directory for Geist font.""" - 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 + font_path = get_default_font_path() + if font_path: + self._font_path = font_path - search_dirs: list[str] = [] - - 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 - - if sys.platform != "win32": - try: - import subprocess - - for pattern in ["GeistMono", "Geist-Mono", "Geist"]: - 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): - self._font_path = font_file - return font_file - except Exception: - pass - - return None + return self._font_path def init(self, width: int, height: int, reuse: bool = False) -> None: """Initialize display with dimensions. @@ -317,7 +156,7 @@ class SixelDisplay: if row_idx >= self.height: break - tokens = _parse_ansi(line) + tokens = parse_ansi(line) x_pos = 0 y_pos = row_idx * self.cell_height diff --git a/engine/display/renderer.py b/engine/display/renderer.py new file mode 100644 index 0000000..81017c0 --- /dev/null +++ b/engine/display/renderer.py @@ -0,0 +1,280 @@ +""" +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 diff --git a/tests/test_sixel.py b/tests/test_sixel.py index ea80f6c..677c74d 100644 --- a/tests/test_sixel.py +++ b/tests/test_sixel.py @@ -69,40 +69,45 @@ class TestSixelAnsiParsing: def test_parse_empty_string(self): """handles empty string.""" - from engine.display.backends.sixel import _parse_ansi + from engine.display.renderer import parse_ansi - result = _parse_ansi("") + result = parse_ansi("") assert len(result) > 0 def test_parse_plain_text(self): """parses plain text without ANSI codes.""" - from engine.display.backends.sixel import _parse_ansi + from engine.display.renderer import parse_ansi - result = _parse_ansi("hello world") + result = parse_ansi("hello world") assert len(result) == 1 text, fg, bg, bold = result[0] assert text == "hello world" def test_parse_with_color_codes(self): """parses ANSI color codes.""" - from engine.display.backends.sixel import _parse_ansi + from engine.display.renderer import parse_ansi - result = _parse_ansi("\033[31mred\033[0m") - assert len(result) == 2 + result = parse_ansi("\033[31mred\033[0m") + assert len(result) == 1 + assert result[0][0] == "red" + assert result[0][1] == (205, 49, 49) def test_parse_with_bold(self): """parses bold codes.""" - from engine.display.backends.sixel import _parse_ansi + from engine.display.renderer import parse_ansi - result = _parse_ansi("\033[1mbold\033[0m") - assert len(result) == 2 + result = parse_ansi("\033[1mbold\033[0m") + assert len(result) == 1 + assert result[0][0] == "bold" + assert result[0][3] is True def test_parse_256_color(self): """parses 256 color codes.""" - from engine.display.backends.sixel import _parse_ansi + from engine.display.renderer import parse_ansi - result = _parse_ansi("\033[38;5;196mred\033[0m") - assert len(result) == 2 + result = parse_ansi("\033[38;5;196mred\033[0m") + assert len(result) == 1 + assert result[0][0] == "red" class TestSixelEncoding: