From f9991c24af31702798c1277875bd16f83c8fde38 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 16 Mar 2026 00:00:53 -0700 Subject: [PATCH] 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 --- engine/display/__init__.py | 2 + engine/display/backends/pygame.py | 223 ++++++++++++++++++++++++++++++ mise.toml | 1 + pyproject.toml | 3 + 4 files changed, 229 insertions(+) create mode 100644 engine/display/backends/pygame.py diff --git a/engine/display/__init__.py b/engine/display/__init__.py index 2494466..b93bb18 100644 --- a/engine/display/__init__.py +++ b/engine/display/__init__.py @@ -10,6 +10,7 @@ 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.pygame import PygameDisplay from engine.display.backends.sixel import SixelDisplay from engine.display.backends.terminal import TerminalDisplay from engine.display.backends.websocket import WebSocketDisplay @@ -78,6 +79,7 @@ class DisplayRegistry: cls.register("websocket", WebSocketDisplay) cls.register("sixel", SixelDisplay) cls.register("kitty", KittyDisplay) + cls.register("pygame", PygameDisplay) cls._initialized = True diff --git a/engine/display/backends/pygame.py b/engine/display/backends/pygame.py new file mode 100644 index 0000000..cececc9 --- /dev/null +++ b/engine/display/backends/pygame.py @@ -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() diff --git a/mise.toml b/mise.toml index 5396f2c..fc0a64f 100644 --- a/mise.toml +++ b/mise.toml @@ -35,6 +35,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-pygame = { run = "uv run mainline.py --display pygame", 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"] } diff --git a/pyproject.toml b/pyproject.toml index 29f3132..4441f5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ websocket = [ sixel = [ "Pillow>=10.0.0", ] +pygame = [ + "pygame>=2.0.0", +] browser = [ "playwright>=1.40.0", ]