forked from genewildish/Mainline
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
This commit is contained in:
@@ -7,6 +7,7 @@ Supports auto-discovery of display backends.
|
|||||||
|
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
|
from engine.display.backends.kitty import KittyDisplay
|
||||||
from engine.display.backends.multi import MultiDisplay
|
from engine.display.backends.multi import MultiDisplay
|
||||||
from engine.display.backends.null import NullDisplay
|
from engine.display.backends.null import NullDisplay
|
||||||
from engine.display.backends.sixel import SixelDisplay
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
@@ -76,6 +77,7 @@ class DisplayRegistry:
|
|||||||
cls.register("null", NullDisplay)
|
cls.register("null", NullDisplay)
|
||||||
cls.register("websocket", WebSocketDisplay)
|
cls.register("websocket", WebSocketDisplay)
|
||||||
cls.register("sixel", SixelDisplay)
|
cls.register("sixel", SixelDisplay)
|
||||||
|
cls.register("kitty", KittyDisplay)
|
||||||
|
|
||||||
cls._initialized = True
|
cls._initialized = True
|
||||||
|
|
||||||
|
|||||||
273
engine/display/backends/kitty.py
Normal file
273
engine/display/backends/kitty.py
Normal file
@@ -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()
|
||||||
@@ -188,6 +188,88 @@ class SixelDisplay:
|
|||||||
self.cell_width = cell_width
|
self.cell_width = cell_width
|
||||||
self.cell_height = cell_height
|
self.cell_height = cell_height
|
||||||
self._initialized = False
|
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:
|
def init(self, width: int, height: int) -> None:
|
||||||
self.width = width
|
self.width = width
|
||||||
@@ -210,12 +292,15 @@ class SixelDisplay:
|
|||||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
try:
|
font_path = self._get_font_path()
|
||||||
font = ImageFont.truetype(
|
font = None
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
if font_path:
|
||||||
self.cell_height - 2,
|
try:
|
||||||
)
|
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
font = None
|
||||||
|
|
||||||
|
if font is None:
|
||||||
try:
|
try:
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
31
kitty_test.py
Normal file
31
kitty_test.py
Normal file
@@ -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()
|
||||||
@@ -34,6 +34,7 @@ run-firehose = "uv run mainline.py --firehose"
|
|||||||
|
|
||||||
run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] }
|
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-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-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"] }
|
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"] }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user