forked from genewildish/Mainline
- Rename VERTICAL camera mode to FEED (rapid single-item view) - Add SCROLL camera mode with float accumulation for smooth movie-credits style scrolling - Add estimate_block_height() for cheap layout calculation without full rendering - Replace ViewportFilterStage with layout-aware filtering that tracks camera position - Add render caching to FontStage to avoid re-rendering items - Fix CameraStage to use global canvas height for scrolling bounds - Add horizontal padding in Camera.apply() to prevent ghosting - Add get_dimensions() to MultiDisplay for proper viewport sizing - Fix PygameDisplay to auto-detect viewport from window size - Update presets to use scroll camera with appropriate speeds
278 lines
9.2 KiB
Python
278 lines
9.2 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
|
|
|
|
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
|
|
|
|
import os
|
|
|
|
os.environ["SDL_VIDEODRIVER"] = "x11"
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
# Apply border if requested
|
|
if border:
|
|
from engine.display import render_border
|
|
|
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
|
|
|
self._screen.fill((0, 0, 0))
|
|
|
|
for row_idx, line in enumerate(buffer[: self.height]):
|
|
if row_idx >= self.height:
|
|
break
|
|
|
|
tokens = parse_ansi(line)
|
|
x_pos = 0
|
|
|
|
for text, fg, bg, _bold 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
|
|
|
|
if monitor:
|
|
chars_in = sum(len(line) for line in buffer)
|
|
monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in)
|
|
|
|
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
|