feat(display): add Pygame native window display backend

- Add PygameDisplay for rendering in native application window
- Add pygame to optional dependencies
- Add run-pygame mise task
This commit is contained in:
2026-03-16 00:00:53 -07:00
parent 20ed014491
commit f9991c24af
4 changed files with 229 additions and 0 deletions

View File

@@ -10,6 +10,7 @@ from typing import Protocol
from engine.display.backends.kitty import KittyDisplay 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.pygame import PygameDisplay
from engine.display.backends.sixel import SixelDisplay from engine.display.backends.sixel import SixelDisplay
from engine.display.backends.terminal import TerminalDisplay from engine.display.backends.terminal import TerminalDisplay
from engine.display.backends.websocket import WebSocketDisplay from engine.display.backends.websocket import WebSocketDisplay
@@ -78,6 +79,7 @@ class DisplayRegistry:
cls.register("websocket", WebSocketDisplay) cls.register("websocket", WebSocketDisplay)
cls.register("sixel", SixelDisplay) cls.register("sixel", SixelDisplay)
cls.register("kitty", KittyDisplay) cls.register("kitty", KittyDisplay)
cls.register("pygame", PygameDisplay)
cls._initialized = True cls._initialized = True

View File

@@ -0,0 +1,223 @@
"""
Pygame display backend - renders to a native application window.
"""
import time
class PygameDisplay:
"""Pygame display backend - renders to native window."""
width: int = 80
height: int = 24
window_width: int = 800
window_height: int = 600
def __init__(
self,
cell_width: int = 10,
cell_height: int = 18,
window_width: int = 800,
window_height: int = 600,
):
self.width = 80
self.height = 24
self.cell_width = cell_width
self.cell_height = cell_height
self.window_width = window_width
self.window_height = window_height
self._initialized = False
self._pygame = None
self._screen = None
self._font = None
def _get_font_path(self) -> str | None:
"""Get font path for rendering."""
import os
import sys
from pathlib import Path
env_font = os.environ.get("MAINLINE_PYGAME_FONT")
if env_font and os.path.exists(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.append(os.path.expanduser("~/Library/Fonts/"))
elif sys.platform == "win32":
search_dirs.append(
os.path.expanduser("~\\AppData\\Local\\Microsoft\\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
return None
def init(self, width: int, height: int) -> None:
self.width = width
self.height = height
try:
import pygame
except ImportError:
return
pygame.init()
self._pygame = pygame
self._screen = pygame.display.set_mode((self.window_width, self.window_height))
pygame.display.set_caption("Mainline")
font_path = self._get_font_path()
if font_path:
try:
self._font = pygame.font.Font(font_path, self.cell_height - 2)
except Exception:
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
else:
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
self._initialized = True
def show(self, buffer: list[str]) -> None:
import sys
if not self._initialized or not self._pygame:
return
t0 = time.perf_counter()
for event in self._pygame.event.get():
if event.type == self._pygame.QUIT:
sys.exit(0)
self._screen.fill((0, 0, 0))
for row_idx, line in enumerate(buffer[: self.height]):
if row_idx >= self.height:
break
tokens = self._parse_ansi(line)
x_pos = 0
for text, fg, bg in tokens:
if not text:
continue
if bg != (0, 0, 0):
bg_surface = self._font.render(text, True, fg, bg)
self._screen.blit(bg_surface, (x_pos, row_idx * self.cell_height))
else:
text_surface = self._font.render(text, True, fg)
self._screen.blit(text_surface, (x_pos, row_idx * self.cell_height))
x_pos += self._font.size(text)[0]
self._pygame.display.flip()
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("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))
self._pygame.display.flip()
def cleanup(self) -> None:
if self._pygame:
self._pygame.quit()

View File

@@ -35,6 +35,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-kitty = { run = "uv run mainline.py --display kitty", depends = ["sync-all"] }
run-pygame = { run = "uv run mainline.py --display pygame", 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"] }

View File

@@ -36,6 +36,9 @@ websocket = [
sixel = [ sixel = [
"Pillow>=10.0.0", "Pillow>=10.0.0",
] ]
pygame = [
"pygame>=2.0.0",
]
browser = [ browser = [
"playwright>=1.40.0", "playwright>=1.40.0",
] ]