"""Terminal context manager for raw-mode interactive input."""
from __future__ import annotations
import os
import select
import signal
import sys
import termios
import tty
from lib import constant as C
from .tui_utils import get_term_width
class TerminalContext:
"""Context manager that puts the terminal in raw mode for interactive input."""
def __init__(self):
self._fd = sys.stdin.fileno()
self._stderr_fd = sys.stderr.fileno()
self._old_settings: list | None = None
self._resized = False
self.width: int = C.MIN_BOX_WIDTH
def __enter__(self) -> 'TerminalContext':
self._old_settings = termios.tcgetattr(self._fd)
tty.setraw(self._fd)
self._write('\033[?25l')
self._write('\033[?1049h')
self.width = get_term_width()
self.clear()
signal.signal(signal.SIGWINCH, self._on_resize)
return self
def __exit__(self, *args) -> None:
signal.signal(signal.SIGWINCH, signal.SIG_DFL)
self._write('\033[?1049l')
self._write('\033[?25h')
if self._old_settings is not None:
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._old_settings)
def _on_resize(self, _signum, _frame):
self._resized = True
self.width = get_term_width()
def _write(self, text: str) -> None:
"""Write directly to the original stderr fd, bypassing sys.stderr.
This ensures TUI output is unaffected when sys.stderr is temporarily
redirected (e.g. during deploy log capture).
"""
os.write(self._stderr_fd, text.encode('utf-8'))
def clear(self) -> None:
"""Clear entire screen and move cursor to (1,1)."""
self._write('\033[2J\033[H')
def move_to(self, row: int, col: int = 1) -> None:
self._write(f'\033[{row};{col}H')
def erase_line(self) -> None:
self._write('\033[K')
def write_at(self, row: int, col: int, text: str) -> None:
self.move_to(row, col)
self._write(text)
def poll_key(self, timeout: float = C.KEY_POLL_INTERVAL) -> str | None:
"""Non-blocking single-byte read via the raw file descriptor.
Uses *os.read* on the bare fd to avoid Python's TextIOWrapper
buffering, which can swallow escape-sequence bytes in raw mode.
"""
r, _, _ = select.select([self._fd], [], [], timeout)
if r:
return os.read(self._fd, 1).decode('latin-1')
return None
def flush_input(self) -> None:
while self.poll_key(timeout=0):
pass
@property
def was_resized(self) -> bool:
r = self._resized
self._resized = False
return r