Files
Mainline/engine/display/backends/pygame.py

371 lines
13 KiB
Python

"""
Pygame display backend - renders to a native application window.
"""
import time
from engine.display.renderer import parse_ansi
class PygameDisplay:
"""Pygame display backend - renders to native window.
Supports reuse mode - when reuse=True, skips SDL initialization
and reuses the existing pygame window from a previous instance.
"""
width: int = 80
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,
target_fps: float = 30.0,
):
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.target_fps = target_fps
self._initialized = False
self._pygame = None
self._screen = None
self._font = None
self._resized = False
self._quit_requested = False
self._last_frame_time = 0.0
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
self._glyph_cache = {}
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, reuse: bool = False) -> None:
"""Initialize display with dimensions.
Args:
width: Terminal width in characters
height: Terminal height in rows
reuse: If True, attach to existing pygame window instead of creating new
"""
self.width = width
self.height = height
try:
import pygame
except ImportError:
return
if reuse and PygameDisplay._pygame_initialized:
self._pygame = pygame
self._initialized = True
return
pygame.init()
pygame.display.set_caption("Mainline")
self._screen = pygame.display.set_mode(
(self.window_width, self.window_height),
pygame.RESIZABLE,
)
self._pygame = pygame
PygameDisplay._pygame_initialized = True
# Calculate character dimensions from actual window size
self.width = max(1, self.window_width // self.cell_width)
self.height = max(1, self.window_height // self.cell_height)
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)
# Check if font supports box-drawing characters; if not, try to find one
self._use_fallback_border = False
if self._font:
try:
# Test rendering some key box-drawing characters
test_chars = ["", "", "", "", "", ""]
for ch in test_chars:
surf = self._font.render(ch, True, (255, 255, 255))
# If surface is empty (width=0 or all black), font lacks glyph
if surf.get_width() == 0:
raise ValueError("Missing glyph")
except Exception:
# Font doesn't support box-drawing, will use line drawing fallback
self._use_fallback_border = True
self._initialized = True
def show(self, buffer: list[str], border: bool = False) -> None:
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:
self._quit_requested = True
elif event.type == self._pygame.KEYDOWN:
if event.key in (self._pygame.K_ESCAPE, self._pygame.K_c):
if event.key == self._pygame.K_c and not (
event.mod & self._pygame.KMOD_LCTRL
or event.mod & self._pygame.KMOD_RCTRL
):
continue
self._quit_requested = True
elif event.type == self._pygame.VIDEORESIZE:
self.window_width = event.w
self.window_height = event.h
self.width = max(1, self.window_width // self.cell_width)
self.height = max(1, self.window_height // self.cell_height)
self._resized = True
# FPS limiting - skip frame if we're going too fast
if self._frame_period > 0:
now = time.perf_counter()
elapsed = now - self._last_frame_time
if elapsed < self._frame_period:
return # Skip this frame
self._last_frame_time = now
# Get metrics for border display
fps = 0.0
frame_time = 0.0
from engine.display import get_monitor
monitor = get_monitor()
if monitor:
stats = monitor.get_stats()
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
frame_count = stats.get("frame_count", 0) if stats else 0
if avg_ms and frame_count > 0:
fps = 1000.0 / avg_ms
frame_time = avg_ms
self._screen.fill((0, 0, 0))
# If border requested but font lacks box-drawing glyphs, use graphical fallback
if border and self._use_fallback_border:
self._draw_fallback_border(fps, frame_time)
# Adjust content area to fit inside border
content_offset_x = self.cell_width
content_offset_y = self.cell_height
self.window_width - 2 * self.cell_width
self.window_height - 2 * self.cell_height
else:
# Normal rendering (with or without text border)
content_offset_x = 0
content_offset_y = 0
if border:
from engine.display import render_border
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
blit_list = []
for row_idx, line in enumerate(buffer[: self.height]):
if row_idx >= self.height:
break
tokens = parse_ansi(line)
x_pos = content_offset_x
for text, fg, bg, _bold in tokens:
if not text:
continue
# Use None as key for no background
bg_key = bg if bg != (0, 0, 0) else None
cache_key = (text, fg, bg_key)
if cache_key not in self._glyph_cache:
# Render and cache
if bg_key is not None:
self._glyph_cache[cache_key] = self._font.render(
text, True, fg, bg_key
)
else:
self._glyph_cache[cache_key] = self._font.render(text, True, fg)
surface = self._glyph_cache[cache_key]
blit_list.append(
(surface, (x_pos, content_offset_y + row_idx * self.cell_height))
)
x_pos += self._font.size(text)[0]
self._screen.blits(blit_list)
# Draw fallback border using graphics if needed
if border and self._use_fallback_border:
self._draw_fallback_border(fps, frame_time)
self._pygame.display.flip()
elapsed_ms = (time.perf_counter() - t0) * 1000
if monitor:
chars_in = sum(len(line) for line in buffer)
monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in)
def _draw_fallback_border(self, fps: float, frame_time: float) -> None:
"""Draw border using pygame graphics primitives instead of text."""
if not self._screen or not self._pygame:
return
# Colors
border_color = (0, 255, 0) # Green (like terminal border)
text_color = (255, 255, 255)
# Calculate dimensions
x1 = 0
y1 = 0
x2 = self.window_width - 1
y2 = self.window_height - 1
# Draw outer rectangle
self._pygame.draw.rect(
self._screen, border_color, (x1, y1, x2 - x1 + 1, y2 - y1 + 1), 1
)
# Draw top border with FPS
if fps > 0:
fps_text = f" FPS:{fps:.0f}"
else:
fps_text = ""
# We need to render this text with a fallback font that has basic ASCII
# Use system font which should have these characters
try:
font = self._font # May not have box chars but should have alphanumeric
text_surf = font.render(fps_text, True, text_color, (0, 0, 0))
text_rect = text_surf.get_rect()
# Position on top border, right-aligned
text_x = x2 - text_rect.width - 5
text_y = y1 + 2
self._screen.blit(text_surf, (text_x, text_y))
except Exception:
pass
# Draw bottom border with frame time
if frame_time > 0:
ft_text = f" {frame_time:.1f}ms"
try:
ft_surf = self._font.render(ft_text, True, text_color, (0, 0, 0))
ft_rect = ft_surf.get_rect()
ft_x = x2 - ft_rect.width - 5
ft_y = y2 - ft_rect.height - 2
self._screen.blit(ft_surf, (ft_x, ft_y))
except Exception:
pass
def clear(self) -> None:
if self._screen and self._pygame:
self._screen.fill((0, 0, 0))
self._pygame.display.flip()
def get_dimensions(self) -> tuple[int, int]:
"""Get current terminal dimensions based on window size.
Returns:
(width, height) in character cells
"""
# Query actual window size and recalculate character cells
if self._screen and self._pygame:
try:
w, h = self._screen.get_size()
if w != self.window_width or h != self.window_height:
self.window_width = w
self.window_height = h
self.width = max(1, w // self.cell_width)
self.height = max(1, h // self.cell_height)
except Exception:
pass
return self.width, self.height
def cleanup(self, quit_pygame: bool = True) -> None:
"""Cleanup display resources.
Args:
quit_pygame: If True, quit pygame entirely. Set to False when
reusing the display to avoid closing shared window.
"""
if quit_pygame and self._pygame:
self._pygame.quit()
PygameDisplay._pygame_initialized = False
@classmethod
def reset_state(cls) -> None:
"""Reset pygame state - useful for testing."""
cls._pygame_initialized = False
def is_quit_requested(self) -> bool:
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
Returns True if the user pressed Ctrl+C, Ctrl+Q, or Escape.
The main loop should check this and raise KeyboardInterrupt.
"""
return self._quit_requested
def clear_quit_request(self) -> bool:
"""Clear the quit request flag after handling.
Returns the previous quit request state.
"""
was_requested = self._quit_requested
self._quit_requested = False
return was_requested