- Added PositioningMode enum (ABSOLUTE, RELATIVE, MIXED) - Created PositionStage class with configurable positioning modes - Updated terminal display to support positioning parameter - Updated PipelineParams to include positioning field - Updated DisplayStage to pass positioning to terminal display - Added documentation in docs/positioning-analysis.md Positioning modes: - ABSOLUTE: Each line has cursor positioning codes (\033[row;1H) - RELATIVE: Lines use newlines (no cursor codes, better for scrolling) - MIXED: Base content uses newlines, effects use absolute positioning (default) Usage: # In pipeline or preset: positioning = "absolute" # or "relative" or "mixed" # Via command line (future): --positioning absolute
162 lines
5.6 KiB
Python
162 lines
5.6 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, positioning: str = "mixed"
|
|
) -> None:
|
|
"""Display buffer with optional border and positioning mode.
|
|
|
|
Args:
|
|
buffer: List of lines to display
|
|
border: Whether to apply border
|
|
positioning: Positioning mode - "mixed" (default), "absolute", or "relative"
|
|
"""
|
|
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)
|
|
|
|
# Apply positioning based on mode
|
|
if positioning == "absolute":
|
|
# All lines should have cursor positioning codes
|
|
# Join with newlines (cursor codes already in buffer)
|
|
output = "\033[H\033[J" + "\n".join(buffer)
|
|
elif positioning == "relative":
|
|
# Remove cursor positioning codes (except colors) and join with newlines
|
|
import re
|
|
|
|
cleaned_buffer = []
|
|
for line in buffer:
|
|
# Remove cursor positioning codes but keep color codes
|
|
# Pattern: \033[row;colH or \033[row;col;...H
|
|
cleaned = re.sub(r"\033\[[0-9;]*H", "", line)
|
|
cleaned_buffer.append(cleaned)
|
|
output = "\033[H\033[J" + "\n".join(cleaned_buffer)
|
|
else: # mixed (default)
|
|
# Current behavior: join with newlines
|
|
# Effects that need absolute positioning have their own cursor codes
|
|
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
|