Files
SimpleFarm/farm/keys.py
T
snowy-yang a7725f8812 chore: 工程化改造前的基线快照
在引入 uv 依赖管理、ruff 静态检查与格式化、pyright 类型检查之前,
先把当前可正常运行的状态固定下来,作为后续大规模自动改动的回滚点。

此时状态:97 项测试全绿,HTTP + WebSocket 多牧场服务器可用。
2026-09-18 21:12:40 +08:00

143 lines
3.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""跨平台非阻塞键盘读取。
Windows 用 ``msvcrt``,POSIX 用 ``termios`` + ``select``,
两端统一返回规范化的按键名,TUI 层无需关心平台差异。
在非交互式环境(管道、重定向)下 ``available`` 为 False,
TUI 会退化为无输入的只读模式。
"""
from __future__ import annotations
import os
import sys
from typing import Optional
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: Optional[int] = 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) -> Optional[str]:
"""非阻塞读取一个按键;没有按键时返回 None。"""
if not self.available:
return None
return self._poll_windows() if IS_WINDOWS else self._poll_posix()
def _poll_windows(self) -> Optional[str]:
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) -> Optional[str]:
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