refactor(display): extract shared rendering logic into renderer.py

- Add renderer.py with parse_ansi(), get_default_font_path(), render_to_pil()
- Update KittyDisplay and SixelDisplay to use shared renderer
- Enhance parse_ansi to handle full ANSI color codes (4-bit, 256-color)
- Update tests to use shared renderer functions
This commit is contained in:
2026-03-16 00:43:23 -07:00
parent f5de2c62e0
commit 0f2d8bf5c2
5 changed files with 316 additions and 384 deletions

View File

@@ -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