"""跨平台非阻塞键盘读取。 Windows 用 ``msvcrt``,POSIX 用 ``termios`` + ``select``, 两端统一返回规范化的按键名,TUI 层无需关心平台差异。 在非交互式环境(管道、重定向)下 ``available`` 为 False, TUI 会退化为无输入的只读模式。 """ from __future__ import annotations import os import sys IS_WINDOWS = os.name == "nt" # Windows 下的扩展键(以 \x00 / \xe0 开头) _WIN_EXTENDED = { "H": "UP", "P": "DOWN", "K": "LEFT", "M": "RIGHT", "G": "HOME", "O": "END", "S": "DELETE", } _WIN_CTRL = { "\x03": "CTRL_C", "\r": "ENTER", "\n": "ENTER", "\t": "TAB", "\x08": "BACKSPACE", "\x1b": "ESC", " ": "SPACE", } class KeyReader: """把终端的原始字节流转换成按键名。""" def __init__(self) -> None: self.available = sys.stdin is not None and sys.stdin.isatty() self._fd: int | None = None self._saved_term = None if self.available and not IS_WINDOWS: try: import termios import tty self._fd = sys.stdin.fileno() self._saved_term = termios.tcgetattr(self._fd) # cbreak: 立即返回按键,但保留 Ctrl-C 等信号 tty.setcbreak(self._fd) except Exception: self.available = False self._saved_term = None if self.available and IS_WINDOWS: try: import msvcrt # noqa: F401 except Exception: self.available = False # ------------------------------------------------------------------ 读取 def poll(self) -> str | None: """非阻塞读取一个按键;没有按键时返回 None。""" if not self.available: return None return self._poll_windows() if IS_WINDOWS else self._poll_posix() def _poll_windows(self) -> str | None: import msvcrt if not msvcrt.kbhit(): return None char = msvcrt.getwch() if char in ("\x00", "\xe0"): if not msvcrt.kbhit(): return None return _WIN_EXTENDED.get(msvcrt.getwch().upper()) if char in _WIN_CTRL: return _WIN_CTRL[char] return char.lower() def _poll_posix(self) -> str | None: import select if self._fd is None: return None try: ready, _, _ = select.select([self._fd], [], [], 0) except (OSError, ValueError): return None if not ready: return None try: data = os.read(self._fd, 8) except OSError: return None if not data: return None text = data.decode("utf-8", errors="ignore") if not text: return None # 方向键:ESC [ A/B/C/D,或 ESC O A/B/C/D if text.startswith("\x1b["): return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3]) if text.startswith("\x1bO"): return {"A": "UP", "B": "DOWN", "C": "RIGHT", "D": "LEFT"}.get(text[2:3]) char = text[0] mapping = { "\x03": "CTRL_C", "\r": "ENTER", "\n": "ENTER", "\t": "TAB", "\x7f": "BACKSPACE", "\x08": "BACKSPACE", "\x1b": "ESC", " ": "SPACE", } if char in mapping: return mapping[char] return char.lower() # ------------------------------------------------------------------ 清理 def close(self) -> None: if self._fd is not None and self._saved_term is not None: try: import termios termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved_term) except Exception: pass self._fd = None self._saved_term = None