""" 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