"""终端界面组件:真彩色样式、方向键选择菜单、隧道运行面板。 非 tty 或设置 NO_COLOR 时自动降级为纯文本,行为不变。 """ from __future__ import annotations import os import sys import threading import time from collections import deque # ---------- 终端能力 ---------- _COLOR = None def _use_color() -> bool: global _COLOR if _COLOR is not None: return _COLOR if os.environ.get("NO_COLOR"): _COLOR = False elif os.environ.get("FORCE_COLOR"): _COLOR = True else: _COLOR = sys.stdout.isatty() if _COLOR and sys.platform == "win32": _enable_vt() return _COLOR def _enable_vt() -> None: try: import ctypes k32 = ctypes.windll.kernel32 h = k32.GetStdHandle(-11) # STD_OUTPUT_HANDLE mode = ctypes.c_uint32() if k32.GetConsoleMode(h, ctypes.byref(mode)): k32.SetConsoleMode(h, mode.value | 0x4) # ENABLE_VIRTUAL_TERMINAL_PROCESSING except Exception: pass # ---------- 调色板(与 GUI 同源:石墨底 + 青绿点缀) ---------- ACCENT = (47, 191, 161) # 青绿 DIM = (151, 161, 173) # 次级文字 FAINT = (110, 120, 131) # 弱化文字 GREEN = (70, 196, 110) RED = (226, 86, 77) AMBER = (217, 162, 60) TEXT = (226, 231, 238) def _c(s: str, rgb: tuple, bold: bool = False) -> str: if not _use_color(): return s r, g, b = rgb prefix = f"\x1b[1m" if bold else "" return f"{prefix}\x1b[38;2;{r};{g};{b}m{s}\x1b[0m" def accent(s: str) -> str: return _c(s, ACCENT) def dim(s: str) -> str: return _c(s, DIM) def faint(s: str) -> str: return _c(s, FAINT) def ok(s: str) -> str: return _c(s, GREEN) def err(s: str) -> str: return _c(s, RED) def warn(s: str) -> str: return _c(s, AMBER) def bold(s: str) -> str: return _c(s, TEXT, bold=True) # ---------- 品牌标识:像素字符画 "❯_"(终端提示符) ---------- _LOGO_ROWS = [ "█.........", "██........", "█.█.......", "██........", "█...██████", ] _CURSOR_FROM = 4 # 第 4 列起是"光标下划线",用绿色 def logo_lines() -> list: """5 行着色 logo;无色模式输出单色块画。""" out = [] for row in _LOGO_ROWS: line = "" for i, ch in enumerate(row): if ch == "█": line += _c("█", GREEN if i >= _CURSOR_FROM else ACCENT) else: line += " " out.append(line) return out # ---------- 显示宽度(CJK 全角按 2 列) ---------- _WIDE = ((0x1100, 0x115F), (0x2E80, 0x303E), (0x3041, 0x33FF), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xA000, 0xA4CF), (0xAC00, 0xD7A3), (0xF900, 0xFAFF), (0xFE30, 0xFE4F), (0xFF00, 0xFF60), (0xFFE0, 0xFFE6)) def disp_len(s: str) -> int: n = 0 for ch in s: o = ord(ch) n += 2 if any(a <= o <= b for a, b in _WIDE) else 1 return n # ---------- 方向键选择菜单 ---------- _UP, _DOWN, _ENTER, _ESC, _OTHER = object(), object(), object(), object(), object() def _read_key() -> object: """读一个键:方向上/下、回车、Esc、其他(返回原字符)。跨平台。""" try: if sys.platform == "win32": import msvcrt ch = msvcrt.getwch() if ch in ("\x00", "\xe0"): ext = msvcrt.getwch() if ext == "H": return _UP if ext == "P": return _DOWN return _OTHER return {"\r": _ENTER, "\n": _ENTER, "\x1b": _ESC}.get(ch, ch) import termios import tty fd = os.open("/dev/tty", os.O_RDONLY) try: old = termios.tcgetattr(fd) tty.setraw(fd) try: ch = os.read(fd, 1).decode("utf-8", "ignore") if ch == "\x1b": rest = os.read(fd, 2).decode("utf-8", "ignore") if rest == "[A": return _UP if rest == "[B": return _DOWN return _ESC return {"\r": _ENTER, "\n": _ENTER}.get(ch, ch) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old) finally: os.close(fd) except Exception: return _OTHER # 无原始终端:退化为编号输入 def select(title: str, options: list, default: int = 0, cancel: bool = True): """交互选择:tty 时 ↑↓ 移动、回车确认、数字直达、q/Esc 取消; 非 tty(管道/自动化)退化为编号输入。返回索引;取消返回 None。""" if len(options) == 1: # 只有一个选项:直接展示并采用,不打扰 print(title) print(f" {accent('❯')} {options[0]}") return 0 if sys.stdin.isatty(): return _select_keys(title, options, default, cancel) return _select_numbered(title, options, default) def _select_numbered(title: str, options: list, default: int) -> int | None: print(title) for i, opt in enumerate(options, 1): mark = bold(opt) if i - 1 == default else dim(opt) print(f" {faint(str(i) + '.')} {mark}") while True: try: raw = input(faint(f" 选择 [1-{len(options)}] (回车 = {default + 1}): ")).strip() except EOFError: return None if not raw: return default if raw.isdigit() and 1 <= int(raw) <= len(options): return int(raw) - 1 print(warn(f" 无效编号,请输入 1-{len(options)}")) def _select_keys(title: str, options: list, default: int, cancel: bool): hint = faint(" ↑↓ 选择 · 回车确认" + (" · q 取消" if cancel else "")) def _paint(cur: int, chosen: bool = False): lines = [] for i, opt in enumerate(options): if i == cur: mark = ok("❯") if chosen else accent("❯") lines.append(f" {mark} {bold(opt)}") else: lines.append(f" {dim(opt)}") return lines print(title) for ln in _paint(default): print(ln) print(hint) sys.stdout.flush() drawn = len(options) + 1 # 选项 + 提示行 cur = default while True: k = _read_key() if k is _OTHER or k is None: sys.stdout.write(f"\x1b[{drawn}A\r" if _use_color() else "") return _select_numbered(" (此终端不支持方向键,请输入编号)", options, cur) if k == _UP or k == "k": cur = (cur - 1) % len(options) elif k == _DOWN or k == "j": cur = (cur + 1) % len(options) elif k == _ENTER: # 定格所选,清掉整个菜单块(选项+提示),只留一行 sys.stdout.write(f"\x1b[{drawn}A\r\x1b[J") print(f" {ok('❯')} {bold(options[cur])}") return cur elif k in (_ESC, "q", "Q", "\x03"): sys.stdout.write(f"\x1b[{drawn}A\r\x1b[J") print(f" {faint('已取消')}") return None elif isinstance(k, str) and k.isdigit() and 1 <= int(k) <= len(options): cur = int(k) - 1 sys.stdout.write(f"\x1b[{drawn}A\r\x1b[J") print(f" {ok('❯')} {bold(options[cur])}") return cur # 重绘选项+提示;帧尾补换行,保证下一帧的上移行数与之一致 out = [f"\x1b[{drawn}A\r"] for ln in _paint(cur): out.append(ln + "\x1b[K\n") out.append(hint + "\x1b[K\n") sys.stdout.write("".join(out)) sys.stdout.flush() # ---------- 隧道运行面板 ---------- def fmt_up(sec: float) -> str: sec = max(0, int(sec)) return f"{sec // 3600:02d}:{sec % 3600 // 60:02d}:{sec % 60:02d}" _STATUS_STYLE = { "connected": (GREEN, "● 已连接"), "connecting": (AMBER, "◐ 连接中"), "error": (RED, "✘ 失败"), "stopped": (FAINT, "○ 已停止"), } _SPIN = "◐◓◑◒" # 连接中旋转帧,按时间取模 class Dashboard: """运行时面板:隧道状态 + 滚动日志,原地刷新。非 tty 时为空操作。""" def __init__(self, logs: int = 4): self._rows = 0 self._logs: deque = deque(maxlen=logs) self._stats: list = [] self._lock = threading.Lock() self._on = sys.stdout.isatty() def log(self, msg: str) -> None: with self._lock: self._logs.append(msg) self._render() def update(self, stats: list) -> None: with self._lock: self._stats = stats self._render() def close(self) -> None: with self._lock: if self._on and self._rows: sys.stdout.write(f"\x1b[{self._rows}A\r\x1b[J") sys.stdout.flush() self._rows = 0 def _render(self) -> None: if not self._on: return lines = [] head = " ❯_ ssh-e2e · 隧道运行中 " lines.append(accent("─" + head) + faint("─" * max(0, 46 - disp_len(head)))) for st in self._stats: if st["status"] == "connecting": glyph = _SPIN[int(time.time() * 2) % len(_SPIN)] dot, text = _c(glyph, AMBER), "连接中" else: rgb, label = _STATUS_STYLE.get(st["status"], (FAINT, "○ 未知")) dot = _c(label.split()[0], rgb) text = " ".join(label.split()[1:]) lines.append(f" {dot} {bold(st['name'])}") m = f"{dim('127.0.0.1:' + str(st['local_port']))} {accent('⇄')} {dim(st['remote'])}" uptime = fmt_up(time.time() - st["started_at"]) if st.get("started_at") else "--:--:--" lines.append(f" {m} {_c(text, AMBER if st['status'] == 'connecting' else _STATUS_STYLE[st['status']][0])} · {faint(uptime)} · {faint('连接 ' + str(st.get('conns', 0)))}") if st.get("error"): lines.append(f" {err(st['error'])}") if self._logs: lines.append(faint("─" * 46)) for e in self._logs: lines.append(f" {faint(e)}") lines.append(faint("─ Ctrl+C 断开 " + "─" * 33)) out = [] if self._rows: out.append(f"\x1b[{self._rows}A\r\x1b[J") out.append("\n".join(lines) + "\n") sys.stdout.write("".join(out)) sys.stdout.flush() self._rows = len(lines)