Files
VPSMaster/proxydoctor/console.py
T

249 lines
9.7 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.
"""Compact terminal output with optional ANSI colour and an iprisk-style theme."""
import os
import re
import sys
import unicodedata
import shutil
_UNSAFE = re.compile(r'[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]')
# ANSI SGR codes used by the themed renderer.
_RESET = '\033[0m'
_CODES = {
'ok': '32', 'bad': '31', 'warn': '33', 'unknown': '33',
'title': '36', 'muted': '90',
# block / badge colours for the iprisk theme
'b_green': '32', 'b_yellow': '33', 'b_red': '31', 'b_cyan': '36',
}
# Reverse-video badge text is rendered with background colours.
_BG = {'b_green': '42', 'b_yellow': '43', 'b_red': '41', 'b_cyan': '46'}
def clean(value):
return _UNSAFE.sub(' ', str(value)).strip()
def _cwidth(ch):
"""Display width of one character (2 for wide CJK, 0 for combining)."""
if unicodedata.combining(ch):
return 0
return 2 if unicodedata.east_asian_width(ch) in ('W', 'F') else 1
def width(value):
return sum(_cwidth(ch) for ch in str(value))
def _pad(value, target):
return value + ' ' * max(0, target - width(value))
def wrap_columns(value, columns):
"""Wrap without losing text or splitting a wide character across columns."""
if columns < 2:
raise ValueError('At least two columns are required')
chunk = ''; used = 0
for ch in clean(value):
size = _cwidth(ch)
if used + size > columns:
yield chunk
chunk = ''; used = 0
chunk += ch; used += size
yield chunk
def can_render_cjk():
"""Best-effort detection of a console that can draw CJK glyphs.
Returns False when we are confident the terminal cannot render Chinese
(e.g. the Linux kernel framebuffer console tty1..tty63, which only ships
ASCII/Latin fonts). Returns True otherwise (we are NOT certain, so this is
a conservative "probably fine" answer).
"""
if sys.platform == 'win32':
return True
tty = ''
try:
tty = os.ttyname(0)
except OSError:
tty = ''
if re.match(r'^/dev/(tty|vc/)\d+', tty):
return False
return True
class Console:
def __init__(self, lang='zh', color='auto'):
self.lang = lang
self.enabled = color == 'always' or (color == 'auto' and sys.stdout.isatty() and 'NO_COLOR' not in os.environ)
self._framed = False # when True, row/keyvalue/section render inside a box
def text(self, zh, en):
return zh if self.lang == 'zh' else en
# ---- basic paint ----
def paint(self, value, status='ok', preserve_space=False):
value = _UNSAFE.sub(' ', str(value)) if preserve_space else clean(value)
if not self.enabled:
return value
code = {'ok': '32', 'bad': '31', 'unknown': '33', 'title': '36', 'muted': '90', 'warn': '33'}[status]
return f'\033[{code}m{value}\033[0m'
def row(self, label, value, status='ok'):
if self._framed:
self.frame_keyvalue(label, value, status)
return
label = clean(label)
w = width(label)
print(label + ' ' * max(1, 19 - w) + self.paint(value, status), flush=True)
def line(self, value='', status='ok'):
if self._framed:
self.frame_line(value, status)
else:
print(self.paint(value, status, preserve_space=True), flush=True)
def section(self, index, zh, en):
if self._framed:
self.frame_section(index, zh, en)
return
print('\n' + self.paint(f'===== {index}. {self.text(zh, en)} =====', 'title'), flush=True)
# ---- iprisk-themed primitives ----
def block(self, value, kind='b_green', pad=None):
"""A solid colour badge like the iprisk report blocks."""
value = clean(value)
pad = (pad if pad is not None else 14) - width(value)
if pad > 0:
value = ' ' * (pad // 2) + value + ' ' * (pad - pad // 2)
if not self.enabled:
return value
return f'\033[{_BG[kind]}m\033[30m{value}\033[0m'
def chip(self, value, status='ok'):
"""A small coloured status chip (绿/黄/红)."""
value = clean(value)
if not self.enabled:
return f'[{value}]'
return f'\033[{_CODES[status]}m[{value}]\033[0m'
def banner(self, text):
"""A large ASCII-art title line, e.g. VPSMASTER."""
text = clean(text)
if not self.enabled:
print(text, flush=True)
return
print('\033[1;36m' + text + _RESET, flush=True)
def hrule(self, ch='=', n=64, status='muted'):
print(self.paint(ch * n, status), flush=True)
# ---- the theme's section header: 「一、基础信息」 with a colour block ----
CN_NUM = ('一', '二', '三', '四', '五', '六', '七', '八', '九', '十',
'十一', '十二', '十三', '十四', '十五', '十六')
def theme_section(self, number, zh, en, kind='b_cyan'):
num = self.CN_NUM[number - 1] if 1 <= number <= len(self.CN_NUM) else str(number)
if self.lang == 'zh':
title = f'{num}、{zh}'
else:
title = f'{number}. {en}'
print()
print(self.block(title, kind, pad=width(title) + 2), flush=True)
def keyvalue(self, label, value, status='ok'):
"""Aligned `label:value` line used by the theme."""
label = clean(label)
sep = ':' if self.lang == 'zh' else ': '
print(' ' + self.paint(label, 'title') + sep + self.paint(value, status), flush=True)
def panel(self, title, rows, width=76):
"""A compact overview; wrap by terminal columns, including CJK text."""
if not 40 <= width <= 120:
raise ValueError('Panel width must be 40..120')
if sys.stdout.isatty():
width = min(width, max(12, shutil.get_terminal_size().columns))
if self._framed:
self.frame_line(title, 'title')
for label, value, status in rows:
self.frame_keyvalue(label, value, status)
return
def pieces(value, limit):
chunk = ''; used = 0
for ch in clean(value):
size = _cwidth(ch)
if used + size > limit and chunk:
yield chunk; chunk = ''; used = 0
chunk += ch; used += size
yield chunk
def line(value, status):
value = clean(value)
vw = sum(_cwidth(ch) for ch in value)
print(self.paint('|', 'title') + ' ' + self.paint(value, status) + ' ' * (width - 4 - vw) + ' ' + self.paint('|', 'title'), flush=True)
print(self.paint('+' + '-' * (width - 2) + '+', 'title'), flush=True)
for value in pieces(title, width - 4):
line(value, 'title')
print(self.paint('+' + '-' * (width - 2) + '+', 'title'), flush=True)
for label, value, status in rows:
text = clean(label) + ': ' + clean(value)
for piece in pieces(text, width - 4):
line(piece, status)
print(self.paint('+' + '-' * (width - 2) + '+', 'title'), flush=True)
# ---- framed theme: the whole report inside one double-line box ----
# Box glyphs: ╔ ═ ╗ ║ ╚ ╝ ╠ ╣
# NOTE: the module-level `width()` helper is shadowed by parameter names in
# some methods, so we keep a local alias `_cw` to the width function.
_cw = staticmethod(width)
def _box(self, left, fill, right, n, status='title'):
"""One horizontal box edge, e.g. ╔═══╗ of width n+2."""
return self.paint(left + fill * n + right, status)
def frame_open(self, title, box_w=76, subtitle=None):
"""Top edge + centered title + optional subtitle, all inside the box."""
if not 40 <= box_w <= 118:
raise ValueError('Frame inner width must be 40..118')
if sys.stdout.isatty():
box_w = min(box_w, max(10, shutil.get_terminal_size().columns - 2))
cw = self._cw
self._box_w = box_w
self._framed = True
print(self._box('╔', '═', '╗', box_w), flush=True)
for text, status in ((title, 'title'), (subtitle, 'muted')):
if text is None:
continue
for piece in wrap_columns(text, box_w):
left = (box_w - cw(piece)) // 2
right = box_w - cw(piece) - left
print(self.paint('║', 'title') + ' ' * left + self.paint(piece, status, preserve_space=True) + ' ' * right + self.paint('║', 'title'), flush=True)
print(self._box('╠', '═', '╣', box_w), flush=True)
def frame_line(self, text, status='ok', pad=1):
"""A plain line inside the box (left border + text + right border)."""
cw = self._cw
if not 0 <= pad <= (self._box_w - 2) // 2:
raise ValueError('Invalid frame padding')
for piece in wrap_columns(text, self._box_w - 2 * pad):
print(self.paint('║', 'title') + ' ' * pad + self.paint(piece, status, preserve_space=True) +
' ' * (self._box_w - pad - cw(piece)) + self.paint('║', 'title'), flush=True)
def frame_keyvalue(self, label, value, status='ok'):
"""Aligned `label:value` line inside the box."""
sep = ':' if self.lang == 'zh' else ': '
self.frame_line(clean(label) + sep + clean(value), status, pad=2)
def frame_section(self, number, zh, en):
"""A yellow section heading inside the box, e.g. 一、基础信息."""
num = self.CN_NUM[number - 1] if 1 <= number <= len(self.CN_NUM) else str(number)
title = f'{num}、{zh}' if self.lang == 'zh' else f'{number}. {en}'
self.frame_line(title, 'warn', pad=2)
def frame_close(self):
"""Bottom edge of the box."""
print(self._box('╚', '═', '╝', self._box_w), flush=True)
self._framed = False