"""PositionStage - Configurable positioning mode for terminal rendering. This module provides positioning stages that allow choosing between different ANSI positioning approaches: - ABSOLUTE: Use cursor positioning codes (\\033[row;colH) for all lines - RELATIVE: Use newlines for all lines - MIXED: Base content uses newlines, effects use cursor positioning (default) """ from enum import Enum from typing import Any from engine.pipeline.core import DataType, PipelineContext, Stage class PositioningMode(Enum): """Positioning mode for terminal rendering.""" ABSOLUTE = "absolute" # All lines have cursor positioning codes RELATIVE = "relative" # Lines use newlines (no cursor codes) MIXED = "mixed" # Mixed: newlines for base, cursor codes for overlays (default) class PositionStage(Stage): """Applies positioning mode to buffer before display. This stage allows configuring how lines are positioned in the terminal: - ABSOLUTE: Each line has \\033[row;colH prefix (precise control) - RELATIVE: Lines are joined with \\n (natural flow) - MIXED: Leaves buffer as-is (effects add their own positioning) """ def __init__( self, mode: PositioningMode = PositioningMode.RELATIVE, name: str = "position" ): self.mode = mode self.name = name self.category = "position" self._mode_str = mode.value def save_state(self) -> dict[str, Any]: """Save positioning mode for restoration.""" return {"mode": self.mode.value} def restore_state(self, state: dict[str, Any]) -> None: """Restore positioning mode from saved state.""" mode_value = state.get("mode", "relative") self.mode = PositioningMode(mode_value) @property def capabilities(self) -> set[str]: return {"position.output"} @property def dependencies(self) -> set[str]: # Position stage typically runs after render but before effects # Effects may add their own positioning codes return {"render.output"} @property def inlet_types(self) -> set: return {DataType.TEXT_BUFFER} @property def outlet_types(self) -> set: return {DataType.TEXT_BUFFER} def init(self, ctx: PipelineContext) -> bool: """Initialize the positioning stage.""" return True def process(self, data: Any, ctx: PipelineContext) -> Any: """Apply positioning mode to the buffer. Args: data: List of strings (buffer lines) ctx: Pipeline context Returns: Buffer with applied positioning mode """ if data is None: return data if not isinstance(data, list): return data if self.mode == PositioningMode.ABSOLUTE: return self._to_absolute(data, ctx) elif self.mode == PositioningMode.RELATIVE: return self._to_relative(data, ctx) else: # MIXED return data # No transformation def _to_absolute(self, data: list[str], ctx: PipelineContext) -> list[str]: """Convert buffer to absolute positioning (all lines have cursor codes). This mode prefixes each line with \\033[row;colH to move cursor to the exact position before writing the line. Args: data: List of buffer lines ctx: Pipeline context (provides terminal dimensions) Returns: Buffer with cursor positioning codes for each line """ result = [] viewport_height = ctx.params.viewport_height if ctx.params else 24 for i, line in enumerate(data): if i >= viewport_height: break # Don't exceed viewport # Check if line already has cursor positioning if "\033[" in line and "H" in line: # Already has cursor positioning - leave as-is result.append(line) else: # Add cursor positioning for this line # Row is 1-indexed result.append(f"\033[{i + 1};1H{line}") return result def _to_relative(self, data: list[str], ctx: PipelineContext) -> list[str]: """Convert buffer to relative positioning (use newlines). This mode removes explicit cursor positioning codes from lines (except for effects that specifically add them). Note: Effects like HUD add their own cursor positioning codes, so we can't simply remove all of them. We rely on the terminal display to join lines with newlines. Args: data: List of buffer lines ctx: Pipeline context (unused) Returns: Buffer with minimal cursor positioning (only for overlays) """ # For relative mode, we leave the buffer as-is # The terminal display handles joining with newlines # Effects that need absolute positioning will add their own codes # Filter out lines that would cause double-positioning result = [] for i, line in enumerate(data): # Check if this line looks like base content (no cursor code at start) # vs an effect line (has cursor code at start) if line.startswith("\033[") and "H" in line[:20]: # This is an effect with positioning - keep it result.append(line) else: # Base content - strip any inline cursor codes (rare) # but keep color codes result.append(line) return result def cleanup(self) -> None: """Clean up positioning stage.""" pass # Convenience function to create positioning stage def create_position_stage( mode: str = "relative", name: str = "position" ) -> PositionStage: """Create a positioning stage with the specified mode. Args: mode: Positioning mode ("absolute", "relative", or "mixed") name: Name for the stage Returns: PositionStage instance """ try: positioning_mode = PositioningMode(mode) except ValueError: positioning_mode = PositioningMode.RELATIVE return PositionStage(mode=positioning_mode, name=name)