""" ANSI terminal display backend. """ import os import select import sys import termios import tty 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 self._raw_mode_enabled: bool = False self._original_termios: list = [] self._quit_requested: bool = False 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 # Disable mouse tracking if enabled self.disable_mouse_tracking() # Restore normal terminal mode if raw mode was enabled self.set_raw_mode(False) print(CURSOR_ON, end="", flush=True) def is_quit_requested(self) -> bool: """Check if quit was requested (optional protocol method).""" return self._quit_requested def clear_quit_request(self) -> None: """Clear quit request (optional protocol method).""" self._quit_requested = False def request_quit(self) -> None: """Request quit (e.g., when Ctrl+C is pressed).""" self._quit_requested = True def enable_mouse_tracking(self) -> None: """Enable SGR mouse tracking mode.""" try: # SGR mouse mode: \x1b[?1006h sys.stdout.write("\x1b[?1006h") sys.stdout.flush() except (OSError, AttributeError): pass # Terminal might not support mouse tracking def disable_mouse_tracking(self) -> None: """Disable SGR mouse tracking mode.""" try: # Disable SGR mouse mode: \x1b[?1006l sys.stdout.write("\x1b[?1006l") sys.stdout.flush() except (OSError, AttributeError): pass def set_raw_mode(self, enable: bool = True) -> None: """Enable/disable raw terminal mode for input capture. When raw mode is enabled: - Keystrokes are read immediately without echo - Special keys (arrows, Ctrl+C, etc.) are captured - Terminal is not in cooked/canonical mode Args: enable: True to enable raw mode, False to restore normal mode """ try: if enable and not self._raw_mode_enabled: # Save original terminal settings self._original_termios = termios.tcgetattr(sys.stdin) # Set raw mode tty.setraw(sys.stdin.fileno()) self._raw_mode_enabled = True # Enable mouse tracking self.enable_mouse_tracking() elif not enable and self._raw_mode_enabled: # Disable mouse tracking self.disable_mouse_tracking() # Restore original terminal settings if self._original_termios: termios.tcsetattr( sys.stdin, termios.TCSADRAIN, self._original_termios ) self._raw_mode_enabled = False except (termios.error, OSError): # Terminal might not support raw mode (e.g., in tests) pass def get_input_keys(self, timeout: float = 0.0) -> list[str]: """Get available keyboard input. Reads available keystrokes from stdin. Should be called with raw mode enabled for best results. Args: timeout: Maximum time to wait for input (seconds) Returns: List of key symbols as strings """ keys = [] try: # Check if input is available if select.select([sys.stdin], [], [], timeout)[0]: char = sys.stdin.read(1) if char == "\x1b": # Escape sequence # Read next characters to determine key # Try to read up to 10 chars for longer sequences seq = sys.stdin.read(10) # PageUp: \x1b[5~ if seq.startswith("[5~"): keys.append("page_up") # PageDown: \x1b[6~ elif seq.startswith("[6~"): keys.append("page_down") # Arrow keys: \x1b[A, \x1b[B, etc. elif seq.startswith("["): if seq[1] == "A": keys.append("up") elif seq[1] == "B": keys.append("down") elif seq[1] == "C": keys.append("right") elif seq[1] == "D": keys.append("left") else: # Unknown escape sequence keys.append("escape") # Mouse events: \x1b[ str | None: """Parse SGR mouse event sequence. Format: \x1b[= 3: try: button = int(parts[0]) x = int(parts[1]) - 1 # Convert to 0-indexed y = int(parts[2]) - 1 return f"mouse:{button}:{x}:{y}" except ValueError: pass return None def is_raw_mode_enabled(self) -> bool: """Check if raw mode is currently enabled.""" return self._raw_mode_enabled