From 20ed014491906b51f8c1cff8e3a111b1e1e05f5e Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 15 Mar 2026 23:56:48 -0700 Subject: [PATCH] feat(display): add Kitty graphics backend and improve font detection - Add KittyDisplay using kitty's native graphics protocol - Improve cross-platform font detection for SixelDisplay - Add run-kitty mise task for testing kitty backend - Add kitty_test.py for testing graphics protocol --- engine/display/__init__.py | 2 + engine/display/backends/kitty.py | 273 +++++++++++++++++++++++++++++++ engine/display/backends/sixel.py | 97 ++++++++++- kitty_test.py | 31 ++++ mise.toml | 1 + 5 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 engine/display/backends/kitty.py create mode 100644 kitty_test.py diff --git a/engine/display/__init__.py b/engine/display/__init__.py index d092de1..2494466 100644 --- a/engine/display/__init__.py +++ b/engine/display/__init__.py @@ -7,6 +7,7 @@ Supports auto-discovery of display backends. from typing import Protocol +from engine.display.backends.kitty import KittyDisplay from engine.display.backends.multi import MultiDisplay from engine.display.backends.null import NullDisplay from engine.display.backends.sixel import SixelDisplay @@ -76,6 +77,7 @@ class DisplayRegistry: cls.register("null", NullDisplay) cls.register("websocket", WebSocketDisplay) cls.register("sixel", SixelDisplay) + cls.register("kitty", KittyDisplay) cls._initialized = True diff --git a/engine/display/backends/kitty.py b/engine/display/backends/kitty.py new file mode 100644 index 0000000..fca8f94 --- /dev/null +++ b/engine/display/backends/kitty.py @@ -0,0 +1,273 @@ +""" +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() diff --git a/engine/display/backends/sixel.py b/engine/display/backends/sixel.py index 56f3991..3e1a2b5 100644 --- a/engine/display/backends/sixel.py +++ b/engine/display/backends/sixel.py @@ -188,6 +188,88 @@ class SixelDisplay: self.cell_width = cell_width self.cell_height = cell_height self._initialized = False + self._font_path = None + + def _get_font_path(self) -> str | None: + """Get font path from env or detect common locations (cross-platform).""" + import os + import sys + from pathlib import Path + + if self._font_path: + return self._font_path + + env_font = os.environ.get("MAINLINE_SIXEL_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: + """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 + + 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 def init(self, width: int, height: int) -> None: self.width = width @@ -210,12 +292,15 @@ class SixelDisplay: img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255)) draw = ImageDraw.Draw(img) - try: - font = ImageFont.truetype( - "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", - self.cell_height - 2, - ) - except Exception: + 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: diff --git a/kitty_test.py b/kitty_test.py new file mode 100644 index 0000000..eed1a95 --- /dev/null +++ b/kitty_test.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Test script for Kitty graphics display.""" + +import sys + + +def test_kitty_simple(): + """Test simple Kitty graphics output with embedded PNG.""" + import base64 + + # Minimal 1x1 red pixel PNG (pre-encoded) + # This is a tiny valid PNG with a red pixel + png_red_1x1 = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00" + b"\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00" + b"\x03\x00\x01\x00\x05\xfe\xd4\x00\x00\x00\x00IEND\xaeB`\x82" + ) + + encoded = base64.b64encode(png_red_1x1).decode("ascii") + + graphic = f"\x1b_Gf=100,t=d,s=1,v=1,c=1,r=1;{encoded}\x1b\\" + sys.stdout.buffer.write(graphic.encode("utf-8")) + sys.stdout.flush() + + print("\n[If you see a red dot above, Kitty graphics is working!]") + print("[If you see nothing or garbage, it's not working]") + + +if __name__ == "__main__": + test_kitty_simple() diff --git a/mise.toml b/mise.toml index a51b61c..5396f2c 100644 --- a/mise.toml +++ b/mise.toml @@ -34,6 +34,7 @@ run-firehose = "uv run mainline.py --firehose" run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] } run-sixel = { run = "uv run mainline.py --display sixel", depends = ["sync-all"] } +run-kitty = { run = "uv run mainline.py --display kitty", depends = ["sync-all"] } run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] } run-client = { run = "mise run run-both & sleep 2 && $(open http://localhost:8766 2>/dev/null || xdg-open http://localhost:8766 2>/dev/null || echo 'Open http://localhost:8766 manually'); wait", depends = ["sync-all"] }