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:
@@ -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
|
import time
|
||||||
|
|
||||||
|
from engine.display.renderer import get_default_font_path, parse_ansi
|
||||||
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:
|
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:
|
def _get_font_path(self) -> str | None:
|
||||||
"""Get font path from env or detect common locations."""
|
"""Get font path from env or detect common locations."""
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
if self._font_path:
|
if self._font_path:
|
||||||
return self._font_path
|
return self._font_path
|
||||||
@@ -152,49 +62,11 @@ class KittyDisplay:
|
|||||||
self._font_path = env_font
|
self._font_path = env_font
|
||||||
return env_font
|
return env_font
|
||||||
|
|
||||||
def search_dir(base_path: str) -> str | None:
|
font_path = get_default_font_path()
|
||||||
if not os.path.exists(base_path):
|
if font_path:
|
||||||
return None
|
self._font_path = font_path
|
||||||
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 = []
|
return self._font_path
|
||||||
|
|
||||||
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:
|
def show(self, buffer: list[str]) -> None:
|
||||||
import sys
|
import sys
|
||||||
@@ -230,7 +102,7 @@ class KittyDisplay:
|
|||||||
if row_idx >= self.height:
|
if row_idx >= self.height:
|
||||||
break
|
break
|
||||||
|
|
||||||
tokens = _parse_ansi(line)
|
tokens = parse_ansi(line)
|
||||||
x_pos = 0
|
x_pos = 0
|
||||||
y_pos = row_idx * self.cell_height
|
y_pos = row_idx * self.cell_height
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Pygame display backend - renders to a native application window.
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from engine.display.renderer import parse_ansi
|
||||||
|
|
||||||
|
|
||||||
class PygameDisplay:
|
class PygameDisplay:
|
||||||
"""Pygame display backend - renders to native window.
|
"""Pygame display backend - renders to native window.
|
||||||
@@ -141,10 +143,10 @@ class PygameDisplay:
|
|||||||
if row_idx >= self.height:
|
if row_idx >= self.height:
|
||||||
break
|
break
|
||||||
|
|
||||||
tokens = self._parse_ansi(line)
|
tokens = parse_ansi(line)
|
||||||
x_pos = 0
|
x_pos = 0
|
||||||
|
|
||||||
for text, fg, bg in tokens:
|
for text, fg, bg, _bold in tokens:
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -168,72 +170,6 @@ class PygameDisplay:
|
|||||||
chars_in = sum(len(line) for line in buffer)
|
chars_in = sum(len(line) for line in buffer)
|
||||||
monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in)
|
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:
|
def clear(self) -> None:
|
||||||
if self._screen and self._pygame:
|
if self._screen and self._pygame:
|
||||||
self._screen.fill((0, 0, 0))
|
self._screen.fill((0, 0, 0))
|
||||||
|
|||||||
@@ -4,105 +4,7 @@ Sixel graphics display backend - renders to sixel graphics in terminal.
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from engine.display.renderer import get_default_font_path, parse_ansi
|
||||||
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)]
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_sixel(image) -> str:
|
def _encode_sixel(image) -> str:
|
||||||
@@ -191,10 +93,8 @@ class SixelDisplay:
|
|||||||
self._font_path = None
|
self._font_path = None
|
||||||
|
|
||||||
def _get_font_path(self) -> str | 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 os
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
if self._font_path:
|
if self._font_path:
|
||||||
return self._font_path
|
return self._font_path
|
||||||
@@ -204,72 +104,11 @@ class SixelDisplay:
|
|||||||
self._font_path = env_font
|
self._font_path = env_font
|
||||||
return env_font
|
return env_font
|
||||||
|
|
||||||
def search_dir(base_path: str) -> str | None:
|
font_path = get_default_font_path()
|
||||||
"""Search directory for Geist font."""
|
if font_path:
|
||||||
if not os.path.exists(base_path):
|
self._font_path = font_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] = []
|
return self._font_path
|
||||||
|
|
||||||
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, reuse: bool = False) -> None:
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
"""Initialize display with dimensions.
|
"""Initialize display with dimensions.
|
||||||
@@ -317,7 +156,7 @@ class SixelDisplay:
|
|||||||
if row_idx >= self.height:
|
if row_idx >= self.height:
|
||||||
break
|
break
|
||||||
|
|
||||||
tokens = _parse_ansi(line)
|
tokens = parse_ansi(line)
|
||||||
x_pos = 0
|
x_pos = 0
|
||||||
y_pos = row_idx * self.cell_height
|
y_pos = row_idx * self.cell_height
|
||||||
|
|
||||||
|
|||||||
280
engine/display/renderer.py
Normal file
280
engine/display/renderer.py
Normal file
@@ -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
|
||||||
@@ -69,40 +69,45 @@ class TestSixelAnsiParsing:
|
|||||||
|
|
||||||
def test_parse_empty_string(self):
|
def test_parse_empty_string(self):
|
||||||
"""handles empty string."""
|
"""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
|
assert len(result) > 0
|
||||||
|
|
||||||
def test_parse_plain_text(self):
|
def test_parse_plain_text(self):
|
||||||
"""parses plain text without ANSI codes."""
|
"""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
|
assert len(result) == 1
|
||||||
text, fg, bg, bold = result[0]
|
text, fg, bg, bold = result[0]
|
||||||
assert text == "hello world"
|
assert text == "hello world"
|
||||||
|
|
||||||
def test_parse_with_color_codes(self):
|
def test_parse_with_color_codes(self):
|
||||||
"""parses ANSI color codes."""
|
"""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")
|
result = parse_ansi("\033[31mred\033[0m")
|
||||||
assert len(result) == 2
|
assert len(result) == 1
|
||||||
|
assert result[0][0] == "red"
|
||||||
|
assert result[0][1] == (205, 49, 49)
|
||||||
|
|
||||||
def test_parse_with_bold(self):
|
def test_parse_with_bold(self):
|
||||||
"""parses bold codes."""
|
"""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")
|
result = parse_ansi("\033[1mbold\033[0m")
|
||||||
assert len(result) == 2
|
assert len(result) == 1
|
||||||
|
assert result[0][0] == "bold"
|
||||||
|
assert result[0][3] is True
|
||||||
|
|
||||||
def test_parse_256_color(self):
|
def test_parse_256_color(self):
|
||||||
"""parses 256 color codes."""
|
"""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")
|
result = parse_ansi("\033[38;5;196mred\033[0m")
|
||||||
assert len(result) == 2
|
assert len(result) == 1
|
||||||
|
assert result[0][0] == "red"
|
||||||
|
|
||||||
|
|
||||||
class TestSixelEncoding:
|
class TestSixelEncoding:
|
||||||
|
|||||||
Reference in New Issue
Block a user