249 lines
9.7 KiB
Python
249 lines
9.7 KiB
Python
"""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
|