Files
sideline/engine/display/backends/terminal.py
David Gwilliam 33df254409 feat(positioning): Add configurable PositionStage for positioning modes
- 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
2026-03-21 17:38:20 -07:00

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