The terminal display was using which concatenated lines without separators, causing text to render incorrectly and appear to jump vertically. Changed to so lines are properly separated with newlines, allowing the terminal to render each line on its own row. The ANSI cursor positioning codes (\033[row;colH) added by effects like HUD and firehose still work correctly because: 1. \033[H moves cursor to (1,1) and \033[J clears screen 2. Newlines move cursor down for subsequent lines 3. Cursor positioning codes override the newline positions 4. This allows effects to position content at specific rows while the base content flows naturally with newlines
134 lines
4.4 KiB
Python
134 lines
4.4 KiB
Python
"""
|
|
ANSI terminal display backend.
|
|
"""
|
|
|
|
import os
|
|
|
|
|
|
class TerminalDisplay:
|
|
"""ANSI terminal display backend.
|
|
|
|
Renders buffer to stdout using ANSI escape codes.
|
|
Supports reuse - when reuse=True, skips re-initializing terminal state.
|
|
Auto-detects terminal dimensions on init.
|
|
"""
|
|
|
|
width: int = 80
|
|
height: int = 24
|
|
_initialized: bool = False
|
|
|
|
def __init__(self, target_fps: float = 30.0):
|
|
self.target_fps = target_fps
|
|
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
|
self._last_frame_time = 0.0
|
|
self._cached_dimensions: tuple[int, int] | None = None
|
|
|
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
|
"""Initialize display with dimensions.
|
|
|
|
If width/height are not provided (0/None), auto-detects terminal size.
|
|
Otherwise uses provided dimensions or falls back to terminal size
|
|
if the provided dimensions exceed terminal capacity.
|
|
|
|
Args:
|
|
width: Desired terminal width (0 = auto-detect)
|
|
height: Desired terminal height (0 = auto-detect)
|
|
reuse: If True, skip terminal re-initialization
|
|
"""
|
|
from engine.terminal import CURSOR_OFF
|
|
|
|
# Auto-detect terminal size (handle case where no terminal)
|
|
try:
|
|
term_size = os.get_terminal_size()
|
|
term_width = term_size.columns
|
|
term_height = term_size.lines
|
|
except OSError:
|
|
# No terminal available (e.g., in tests)
|
|
term_width = width if width > 0 else 80
|
|
term_height = height if height > 0 else 24
|
|
|
|
# Use provided dimensions if valid, otherwise use terminal size
|
|
if width > 0 and height > 0:
|
|
self.width = min(width, term_width)
|
|
self.height = min(height, term_height)
|
|
else:
|
|
self.width = term_width
|
|
self.height = term_height
|
|
|
|
if not reuse or not self._initialized:
|
|
print(CURSOR_OFF, end="", flush=True)
|
|
self._initialized = True
|
|
|
|
def get_dimensions(self) -> tuple[int, int]:
|
|
"""Get current terminal dimensions.
|
|
|
|
Returns cached dimensions to avoid querying terminal every frame,
|
|
which can cause inconsistent results. Dimensions are only refreshed
|
|
when they actually change.
|
|
|
|
Returns:
|
|
(width, height) in character cells
|
|
"""
|
|
try:
|
|
term_size = os.get_terminal_size()
|
|
new_dims = (term_size.columns, term_size.lines)
|
|
except OSError:
|
|
new_dims = (self.width, self.height)
|
|
|
|
# Only update cached dimensions if they actually changed
|
|
if self._cached_dimensions is None or self._cached_dimensions != new_dims:
|
|
self._cached_dimensions = new_dims
|
|
self.width = new_dims[0]
|
|
self.height = new_dims[1]
|
|
|
|
return self._cached_dimensions
|
|
|
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
|
import sys
|
|
|
|
from engine.display import get_monitor, render_border
|
|
|
|
# Note: Frame rate limiting is handled by the caller (e.g., FrameTimer).
|
|
# This display renders every frame it receives.
|
|
|
|
# Get metrics for border display
|
|
fps = 0.0
|
|
frame_time = 0.0
|
|
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
|
|
from engine.display import BorderMode
|
|
|
|
if border and border != BorderMode.OFF:
|
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
|
|
|
# Write buffer with cursor home + erase down to avoid flicker
|
|
output = "\033[H\033[J" + "\n".join(buffer)
|
|
sys.stdout.buffer.write(output.encode())
|
|
sys.stdout.flush()
|
|
|
|
def clear(self) -> None:
|
|
from engine.terminal import CLR
|
|
|
|
print(CLR, end="", flush=True)
|
|
|
|
def cleanup(self) -> None:
|
|
from engine.terminal import CURSOR_ON
|
|
|
|
print(CURSOR_ON, end="", flush=True)
|
|
|
|
def is_quit_requested(self) -> bool:
|
|
"""Check if quit was requested (optional protocol method)."""
|
|
return False
|
|
|
|
def clear_quit_request(self) -> None:
|
|
"""Clear quit request (optional protocol method)."""
|
|
pass
|