"""TUI 转换层 - 强大的 WebUI 到终端界面转换引擎 v1.3 本模块提供完整的 HTML/CSS/JS 到 TUI 的转换能力,参考 opencode 风格设计: - HTML 解析:识别 data-tui-* 标记、语义化标签、Aria 属性,转换为终端元素 - CSS 转换:支持终端兼容样式(ANSI 256 色、真彩色、字体排版、边框、阴影效果模拟) - JS 交互:完整模拟鼠标位置追踪、点击事件、键盘绑定、DOM 操作、事件冒泡 - 布局引擎:支持 flex/grid/absolute 布局的终端适配,自动响应式调整 - 组件系统:40+ 种终端组件(按钮、面板、列表、表单、表格、进度条、图表等) - 动画系统:支持帧动画、过渡效果、加载动画 - 主题系统:支持多主题切换、颜色变量、样式继承 - 虚拟滚动:支持大列表性能优化 - 焦点管理:支持 Tab 键导航、焦点环 - 辅助功能:支持 Aria 标签、屏幕阅读器友好 架构设计完全参考 opencode 风格,提供现代化、高性能终端体验。 """ import re import json import html import hashlib from pathlib import Path from typing import Dict, List, Any, Optional, Callable, Tuple, Union, Set from dataclasses import dataclass, field from enum import Enum, auto from collections import defaultdict import os import sys import time import threading from abc import ABC, abstractmethod import weakref # ==================== 基础类型定义 ==================== class TUIElementType(Enum): """TUI 元素类型 - 40+ 种组件类型""" # 容器类 CONTAINER = "container" BOX = "box" PANEL = "panel" CARD = "card" MODAL = "modal" DIALOG = "dialog" DROPDOWN = "dropdown" # 文本类 LABEL = "label" HEADING = "heading" PARAGRAPH = "paragraph" SPAN = "span" CODE = "code" BLOCKQUOTE = "blockquote" # 输入类 INPUT = "input" TEXTAREA = "textarea" SELECT = "select" CHECKBOX = "checkbox" RADIO = "radio" TOGGLE = "toggle" SLIDER = "slider" # 按钮类 BUTTON = "button" ICON_BUTTON = "icon_button" MENU_BUTTON = "menu_button" # 列表类 LIST = "list" LIST_ITEM = "list_item" TABLE = "table" TABLE_ROW = "table_row" TABLE_CELL = "table_cell" TREE = "tree" TREE_NODE = "tree_node" # 导航类 NAV = "nav" TABS = "tabs" TAB = "tab" BREADCRUMB = "breadcrumb" PAGINATION = "pagination" SIDEBAR = "sidebar" # 反馈类 ALERT = "alert" TOAST = "toast" NOTIFICATION = "notification" BADGE = "badge" TAG = "tag" TOOLTIP = "tooltip" # 数据展示类 PROGRESS = "progress" SPINNER = "spinner" SKELETON = "skeleton" AVATAR = "avatar" IMAGE_PLACEHOLDER = "image_placeholder" # 分隔类 SEPARATOR = "separator" SPACER = "spacer" DIVIDER = "divider" # 布局类 HEADER = "header" FOOTER = "footer" SECTION = "section" ARTICLE = "article" ASIDE = "aside" MAIN = "main" GRID = "grid" FLEX = "flex" # 特殊类 CHART = "chart" GRAPH = "graph" TERMINAL = "terminal" LOG_VIEWER = "log_viewer" FILE_TREE = "file_tree" STATUS_BAR = "status_bar" class ANSIStyle: """ANSI 样式常量 - 支持 256 色和真彩色""" RESET = '\x1b[0m' BOLD = '\x1b[1m' DIM = '\x1b[2m' ITALIC = '\x1b[3m' UNDERLINE = '\x1b[4m' BLINK_SLOW = '\x1b[5m' BLINK_FAST = '\x1b[6m' REVERSE = '\x1b[7m' HIDDEN = '\x1b[8m' STRIKETHROUGH = '\x1b[9m' # 标准前景色 FG_BLACK = '\x1b[30m' FG_RED = '\x1b[31m' FG_GREEN = '\x1b[32m' FG_YELLOW = '\x1b[33m' FG_BLUE = '\x1b[34m' FG_MAGENTA = '\x1b[35m' FG_CYAN = '\x1b[36m' FG_WHITE = '\x1b[37m' FG_DEFAULT = '\x1b[39m' # 亮色前景色 FG_BRIGHT_BLACK = '\x1b[90m' FG_BRIGHT_RED = '\x1b[91m' FG_BRIGHT_GREEN = '\x1b[92m' FG_BRIGHT_YELLOW = '\x1b[93m' FG_BRIGHT_BLUE = '\x1b[94m' FG_BRIGHT_MAGENTA = '\x1b[95m' FG_BRIGHT_CYAN = '\x1b[96m' FG_BRIGHT_WHITE = '\x1b[97m' # 标准背景色 BG_BLACK = '\x1b[40m' BG_RED = '\x1b[41m' BG_GREEN = '\x1b[42m' BG_YELLOW = '\x1b[43m' BG_BLUE = '\x1b[44m' BG_MAGENTA = '\x1b[45m' BG_CYAN = '\x1b[46m' BG_WHITE = '\x1b[47m' BG_DEFAULT = '\x1b[49m' # 亮色背景色 BG_BRIGHT_BLACK = '\x1b[100m' BG_BRIGHT_RED = '\x1b[101m' BG_BRIGHT_GREEN = '\x1b[102m' BG_BRIGHT_YELLOW = '\x1b[103m' BG_BRIGHT_BLUE = '\x1b[104m' BG_BRIGHT_MAGENTA = '\x1b[105m' BG_BRIGHT_CYAN = '\x1b[106m' BG_BRIGHT_WHITE = '\x1b[107m' # 256 色支持 @staticmethod def fg_256(color: int) -> str: if not (0 <= color <= 255): color = max(0, min(255, color)) return f'\x1b[38;5;{color}m' @staticmethod def bg_256(color: int) -> str: if not (0 <= color <= 255): color = max(0, min(255, color)) return f'\x1b[48;5;{color}m' # 真彩色 (RGB) 支持 @staticmethod def fg_rgb(r: int, g: int, b: int) -> str: r, g, b = max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)) return f'\x1b[38;2;{r};{g};{b}m' @staticmethod def bg_rgb(r: int, g: int, b: int) -> str: r, g, b = max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)) return f'\x1b[48;2;{r};{g};{b}m' # 颜色转换工具 @staticmethod def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]: """将十六进制颜色转换为 RGB""" hex_color = hex_color.lstrip('#') if len(hex_color) == 3: hex_color = ''.join(c * 2 for c in hex_color) try: return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) except ValueError: return (255, 255, 255) @staticmethod def rgb_to_ansi_256(r: int, g: int, b: int) -> int: """将 RGB 转换为最接近的 256 色索引""" if r == g == b: # 灰度色 if r < 8: return 16 if r > 248: return 231 return round(((r - 8) / 240) * 23) + 232 else: # 彩色 return 16 + (36 * round(r / 255 * 5)) + (6 * round(g / 255 * 5)) + round(b / 255 * 5) class BorderStyle: """边框样式 - 多种预设和自定义边框""" NONE = ("", "", "", "", "", "", "", "") SINGLE = ("┌", "─", "┐", "│", "│", "└", "─", "┘") DOUBLE = ("╔", "═", "╗", "║", "║", "╚", "═", "╝") ROUNDED = ("╭", "─", "╮", "│", "│", "╰", "─", "╯") BOLD = ("┏", "━", "┓", "┃", "┃", "┗", "━", "┛") ASCII = ("+", "-", "+", "|", "|", "+", "-", "+") DASHED = ("┌", "╌", "┐", "╎", "╎", "└", "╌", "┘") DOTTED = ("┌", "┄", "┐", "┆", "┆", "└", "┄", "┘") THICK_DOUBLE = ("🟥", "🟥", "🟥", "🟥", "🟥", "🟥", "🟥", "🟥") BLOCK = ("█", "█", "█", "█", "█", "█", "█", "█") SHADOW = ("▗", "▀", "▖", "▐", "▌", "▝", "▄", "▘") @classmethod def get_style(cls, name: str) -> Tuple[str, ...]: """获取边框样式""" return getattr(cls, name.upper(), cls.SINGLE) @dataclass class TUIColor: """TUI 颜色类 - 支持多种颜色格式""" r: int = 255 g: int = 255 b: int = 255 a: float = 1.0 @classmethod def from_hex(cls, hex_color: str) -> 'TUIColor': hex_color = hex_color.lstrip('#') if len(hex_color) == 3: hex_color = ''.join(c * 2 for c in hex_color) if len(hex_color) >= 6: r = int(hex_color[0:2], 16) g = int(hex_color[2:4], 16) b = int(hex_color[4:6], 16) a = int(hex_color[6:8], 16) / 255.0 if len(hex_color) >= 8 else 1.0 return cls(r, g, b, a) return cls() @classmethod def from_name(cls, name: str) -> 'TUIColor': color_names = { 'black': (0, 0, 0), 'white': (255, 255, 255), 'red': (255, 0, 0), 'green': (0, 255, 0), 'blue': (0, 0, 255), 'yellow': (255, 255, 0), 'cyan': (0, 255, 255), 'magenta': (255, 0, 255), 'orange': (255, 165, 0), 'purple': (128, 0, 128), 'pink': (255, 192, 203), 'gray': (128, 128, 128), 'grey': (128, 128, 128), } rgb = color_names.get(name.lower(), (255, 255, 255)) return cls(*rgb) def to_ansi_fg(self, use_256: bool = True) -> str: if use_256: idx = ANSIStyle.rgb_to_ansi_256(self.r, self.g, self.b) return ANSIStyle.fg_256(idx) return ANSIStyle.fg_rgb(self.r, self.g, self.b) def to_ansi_bg(self, use_256: bool = True) -> str: if use_256: idx = ANSIStyle.rgb_to_ansi_256(self.r, self.g, self.b) return ANSIStyle.bg_256(idx) return ANSIStyle.bg_rgb(self.r, self.g, self.b) # ==================== 样式系统 ==================== @dataclass class TUIStyle: """TUI 样式 - 完整的 CSS 样式映射""" # 颜色 fg_color: Optional[TUIColor] = None bg_color: Optional[TUIColor] = None # 字体样式 bold: bool = False dim: bool = False italic: bool = False underline: bool = False blink: bool = False reverse: bool = False hidden: bool = False strikethrough: bool = False # 布局 width: Optional[int] = None height: Optional[int] = None min_width: int = 0 min_height: int = 0 max_width: Optional[int] = None max_height: Optional[int] = None # 边距和内边距 margin_top: int = 0 margin_right: int = 0 margin_bottom: int = 0 margin_left: int = 0 padding_top: int = 0 padding_right: int = 0 padding_bottom: int = 0 padding_left: int = 0 # 对齐 text_align: str = "left" # left, center, right vertical_align: str = "top" # top, middle, bottom # 边框 border_style: str = "none" border_color: Optional[TUIColor] = None border_width: int = 1 border_radius: int = 0 # 阴影(模拟) shadow: bool = False shadow_char: str = "░" # 透明度(模拟) opacity: float = 1.0 # 溢出处理 overflow_x: str = "clip" # clip, scroll, wrap overflow_y: str = "clip" # 显示 display: str = "block" # block, inline, none visibility: str = "visible" # visible, hidden # 光标 cursor: str = "default" # default, pointer, text, none # 动画 animation: Optional[str] = None transition: Optional[str] = None # 自定义属性 custom_props: Dict[str, Any] = field(default_factory=dict) def apply(self, text: str, strip: bool = False) -> str: """应用样式到文本""" if strip or self.display == "none" or self.visibility == "hidden": return text result = text # 应用字体样式(顺序很重要) if self.bold: result = f"{ANSIStyle.BOLD}{result}" if self.dim: result = f"{ANSIStyle.DIM}{result}" if self.italic: result = f"{ANSIStyle.ITALIC}{result}" if self.underline: result = f"{ANSIStyle.UNDERLINE}{result}" if self.blink: result = f"{ANSIStyle.BLINK_SLOW}{result}" if self.reverse: result = f"{ANSIStyle.REVERSE}{result}" if self.strikethrough: result = f"{ANSIStyle.STRIKETHROUGH}{result}" # 应用颜色 if self.fg_color: result = f"{self.fg_color.to_ansi_fg()}{result}" if self.bg_color: result = f"{self.bg_color.to_ansi_bg()}{result}" # 添加重置码 if any([ self.bold, self.dim, self.italic, self.underline, self.blink, self.reverse, self.hidden, self.strikethrough, self.fg_color, self.bg_color ]): result = f"{result}{ANSIStyle.RESET}" return result def merge(self, other: 'TUIStyle') -> 'TUIStyle': """合并样式(other 覆盖 self)""" merged = TUIStyle() for attr in self.__dataclass_fields__: self_val = getattr(self, attr) other_val = getattr(other, attr) # 如果 other 的值不是默认值,使用 other 的值 if other_val is not None and other_val != self.__dataclass_fields__[attr].default: setattr(merged, attr, other_val) else: setattr(merged, attr, self_val) return merged @classmethod def from_dict(cls, props: Dict[str, Any]) -> 'TUIStyle': """从字典创建样式""" style = cls() for key, value in props.items(): key = key.replace('-', '_') if hasattr(style, key): if key in ('fg_color', 'bg_color', 'border_color'): if isinstance(value, str): if value.startswith('#'): value = TUIColor.from_hex(value) else: value = TUIColor.from_name(value) elif key in ('bold', 'dim', 'italic', 'underline', 'blink', 'reverse', 'hidden', 'strikethrough', 'shadow'): value = bool(value) elif key in ('width', 'height', 'min_width', 'min_height', 'max_width', 'max_height', 'margin_top', 'margin_right', 'margin_bottom', 'margin_left', 'padding_top', 'padding_right', 'padding_bottom', 'padding_left', 'border_width', 'border_radius'): try: value = int(value) except (ValueError, TypeError): continue elif key in ('opacity',): try: value = float(value) except (ValueError, TypeError): continue setattr(style, key, value) return style @dataclass class TUIStyle: """TUI 样式""" fg_color: str = "" bg_color: str = "" bold: bool = False dim: bool = False underline: bool = False italic: bool = False reverse: bool = False def apply(self, text: str) -> str: """应用样式到文本""" result = text if self.bold: result = f"{ANSIStyle.BOLD}{result}" if self.dim: result = f"{ANSIStyle.DIM}{result}" if self.underline: result = f"{ANSIStyle.UNDERLINE}{result}" if self.italic: result = f"{ANSIStyle.ITALIC}{result}" if self.reverse: result = f"{ANSIStyle.REVERSE}{result}" if self.fg_color: result = f"{self.fg_color}{result}" if self.bg_color: result = f"{self.bg_color}{result}" if any([self.bold, self.dim, self.underline, self.italic, self.reverse, self.fg_color, self.bg_color]): result = f"{result}{ANSIStyle.RESET}" return result @dataclass class TUIElement: """TUI 元素基类""" id: str = "" element_type: TUIElementType = TUIElementType.CONTAINER classes: List[str] = field(default_factory=list) text: str = "" x: int = 0 y: int = 0 width: int = 80 height: int = 1 style: TUIStyle = field(default_factory=TUIStyle) children: List['TUIElement'] = field(default_factory=list) attributes: Dict[str, Any] = field(default_factory=dict) parent: Optional['TUIElement'] = None def render(self) -> str: """渲染元素""" return self.style.apply(self.text) def get_bounds(self) -> Tuple[int, int, int, int]: """获取边界 (x, y, width, height)""" return (self.x, self.y, self.width, self.height) @dataclass class TUIButton(TUIElement): """按钮""" action: str = "" target: str = "" clickable: bool = True shortcut: str = "" def render(self) -> str: text = self.text if self.shortcut: text = f"[{self.shortcut}] {text}" # 按钮样式 btn_text = f"▌ {text} ▐" styled = self.style.apply(btn_text) # 填充到指定宽度 padding = self.width - len(btn_text) if padding > 0: styled += " " * padding return styled @dataclass class TUILabel(TUIElement): """标签""" alignment: str = "left" # left, center, right def render(self) -> str: text = self.style.apply(self.text) if self.alignment == "center": padding = (self.width - len(self.text)) // 2 text = " " * padding + text elif self.alignment == "right": padding = self.width - len(self.text) text = " " * padding + text # 填充剩余空间 remaining = self.width - len(self.text) if remaining > 0 and self.alignment == "left": text += " " * remaining return text @dataclass class TUIPanel(TUIElement): """面板/卡片""" border_style: str = "single" title: str = "" show_border: bool = True def render(self) -> str: borders = getattr(BorderStyle, self.border_style.upper(), BorderStyle.SINGLE) lines = [] width = self.width - 2 if self.show_border else self.width # 顶部边框 if self.show_border: if self.title: title_padding = (width - len(self.title)) // 2 top = borders[0] + borders[1] * title_padding + f" {self.title} " + borders[1] * (width - title_padding - len(self.title) - 1) + borders[2] else: top = borders[0] + borders[1] * width + borders[2] lines.append(top) # 内容 for child in self.children: content = child.render() if self.show_border: # 截断过长的内容 content = content[:width].ljust(width) lines.append(f"{borders[3]} {content} {borders[4]}") else: lines.append(content) # 底部边框 if self.show_border: bottom = borders[5] + borders[6] * width + borders[7] lines.append(bottom) return "\n".join(lines) @dataclass class TUILayout(TUIElement): """布局容器""" layout_type: str = "vertical" # vertical, horizontal, grid gap: int = 1 def render(self, width: int = 80, height: int = 24) -> str: if self.layout_type == "vertical": rendered = [] for i, child in enumerate(self.children): child.y = self.y + sum(len(r.render().split('\n')) for r in rendered) + (i * self.gap) rendered.append(child) return "\n".join(el.render() for el in rendered) elif self.layout_type == "horizontal": rendered = [] current_x = self.x for child in self.children: child.x = current_x rendered.append(child) current_x += child.width + self.gap return " ".join(el.render() for el in rendered) else: return "\n".join(el.render() for el in self.children) @dataclass class TUIList(TUIElement): """列表""" items: List[str] = field(default_factory=list) selected_index: int = 0 show_numbers: bool = True def render(self) -> str: lines = [] for i, item in enumerate(self.items): prefix = f"{i + 1}. " if self.show_numbers else " " marker = "► " if i == self.selected_index else " " line = f"{marker}{prefix}{item}" if len(line) < self.width: line += " " * (self.width - len(line)) lines.append(line[:self.width]) return "\n".join(lines) @dataclass class TUISeparator(TUIElement): """分隔线""" char: str = "─" def render(self) -> str: return self.char * self.width @dataclass class TUIProgressBar(TUIElement): """进度条""" progress: float = 0.0 # 0.0 to 1.0 filled_char: str = "█" empty_char: str = "░" def render(self) -> str: filled_width = int(self.width * self.progress) empty_width = self.width - filled_width bar = self.filled_char * filled_width + self.empty_char * empty_width percentage = f" {int(self.progress * 100)}%" return f"{bar}{percentage}" @dataclass class TUISpinner(TUIElement): """加载动画""" frames: List[str] = field(default_factory=lambda: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) current_frame: int = 0 def render(self) -> str: frame = self.frames[self.current_frame % len(self.frames)] return f"{frame} {self.text}" def next_frame(self): self.current_frame += 1 class HTMLToTUIConverter: """强大的 HTML 到 TUI 转换器 支持: - 解析 HTML 结构和 data-tui-* 标记 - 提取 CSS 样式并转换为 ANSI - 解析 JS 交互配置 - 智能布局适配 """ COLOR_MAP = { '#000000': ANSIStyle.FG_BLACK, '#0000ff': ANSIStyle.FG_BLUE, '#008000': ANSIStyle.FG_GREEN, '#00ffff': ANSIStyle.FG_CYAN, '#ff0000': ANSIStyle.FG_RED, '#ff00ff': ANSIStyle.FG_MAGENTA, '#ffff00': ANSIStyle.FG_YELLOW, '#ffffff': ANSIStyle.FG_WHITE, '#808080': ANSIStyle.DIM, '#c0c0c0': ANSIStyle.FG_WHITE, 'black': ANSIStyle.FG_BLACK, 'blue': ANSIStyle.FG_BLUE, 'green': ANSIStyle.FG_GREEN, 'cyan': ANSIStyle.FG_CYAN, 'red': ANSIStyle.FG_RED, 'magenta': ANSIStyle.FG_MAGENTA, 'yellow': ANSIStyle.FG_YELLOW, 'white': ANSIStyle.FG_WHITE, 'gray': ANSIStyle.DIM, 'grey': ANSIStyle.DIM, } BG_COLOR_MAP = { '#000000': ANSIStyle.BG_BLACK, '#0000ff': ANSIStyle.BG_BLUE, '#008000': ANSIStyle.BG_GREEN, '#00ffff': ANSIStyle.BG_CYAN, '#ff0000': ANSIStyle.BG_RED, '#ff00ff': ANSIStyle.BG_MAGENTA, '#ffff00': ANSIStyle.BG_YELLOW, '#ffffff': ANSIStyle.BG_WHITE, 'black': ANSIStyle.BG_BLACK, 'blue': ANSIStyle.BG_BLUE, 'green': ANSIStyle.BG_GREEN, 'cyan': ANSIStyle.BG_CYAN, 'red': ANSIStyle.BG_RED, 'magenta': ANSIStyle.BG_MAGENTA, 'yellow': ANSIStyle.BG_YELLOW, 'white': ANSIStyle.BG_WHITE, } def __init__(self, width: int = 80, height: int = 24): self.width = width self.height = height self.keyboard_bindings: Dict[str, Dict] = {} self.mouse_handlers: Dict[str, Callable] = {} self.css_styles: Dict[str, TUIStyle] = {} def parse(self, html_content: str) -> TUILayout: """解析 HTML 并转换为 TUI 元素树""" # 移除 script 标签(除了 TUI 配置脚本) html_clean = self._extract_tui_scripts(html_content) html_no_script = re.sub(r']*>.*?', '', html_clean, flags=re.DOTALL) # 提取 TUI 配置 self._parse_tui_config(html_content) # 提取 CSS self._parse_tui_css(html_content) # 创建布局 layout = TUILayout(layout_type="vertical") # 提取标题 title_match = re.search(r'(.*?)', html_no_script, re.IGNORECASE) if title_match: header = TUILabel( text=title_match.group(1).strip(), style=TUIStyle(bold=True), width=self.width ) layout.children.append(header) layout.children.append(TUISeparator()) # 提取主体内容 body_match = re.search(r']*>(.*?)', html_no_script, re.IGNORECASE | re.DOTALL) if body_match: body_html = body_match.group(1) elements = self._parse_elements(body_html) layout.children.extend(elements) # 提取导航 nav_elements = self._extract_nav(html_no_script) if nav_elements: layout.children.append(TUISeparator(char="─")) layout.children.append(TUILabel(text="导航菜单", style=TUIStyle(dim=True))) layout.children.extend(nav_elements) # 提取按钮 btn_elements = self._extract_buttons(html_no_script) if btn_elements: layout.children.append(TUISeparator(char="─")) layout.children.extend(btn_elements) return layout def _extract_tui_scripts(self, html: str) -> str: """提取 TUI 配置脚本""" # 保存 TUI 配置脚本 for match in re.finditer(r']*type=["\']application/x-tui-config["\'][^>]*>(.*?)', html, re.DOTALL): try: config = json.loads(match.group(1).strip()) if 'keyboard' in config: self.keyboard_bindings = config['keyboard'] except json.JSONDecodeError: pass return html def _parse_tui_config(self, html: str): """解析 TUI 配置""" for match in re.finditer(r']*type=["\']application/x-tui-config["\'][^>]*>(.*?)', html, re.DOTALL): try: config = json.loads(match.group(1).strip()) if 'keyboard' in config: self.keyboard_bindings = config['keyboard'] if 'mouse' in config: mouse_config = config['mouse'] if mouse_config.get('enabled'): self.mouse_handlers['click'] = lambda x, y: {'action': 'select'} if 'display' in config: display = config['display'] self.width = display.get('width', self.width) self.height = display.get('height', self.height) except json.JSONDecodeError: pass def _parse_tui_css(self, html: str): """解析 TUI CSS""" for match in re.finditer(r']*type=["\']text/x-tui-css["\'][^>]*>(.*?)', html, re.DOTALL): css = match.group(1) # 简单的 CSS 解析 for rule_match in re.finditer(r'([.#]?[\w-]+)\s*\{([^}]+)\}', css): selector = rule_match.group(1) properties = rule_match.group(2) style = self._parse_css_properties(properties) self.css_styles[selector] = style def _parse_css_properties(self, css_text: str) -> TUIStyle: """解析 CSS 属性为 TUI 样式""" style = TUIStyle() # 背景色 bg_match = re.search(r'background(-color)?:\s*(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)', css_text) if bg_match: color = bg_match.group(2).lower() style.bg_color = self.BG_COLOR_MAP.get(color, "") # 文字颜色 color_match = re.search(r'color:\s*(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)', css_text) if color_match: color = color_match.group(1).lower() style.fg_color = self.COLOR_MAP.get(color, "") # 字体样式 if 'font-weight: bold' in css_text or 'font-weight:bold' in css_text: style.bold = True if 'font-style: italic' in css_text: style.italic = True if 'text-decoration: underline' in css_text: style.underline = True return style def _parse_elements(self, html: str) -> List[TUIElement]: """解析 HTML 元素""" elements = [] # 解析带 data-tui-* 标记的元素 for match in re.finditer(r'<(\w+)([^>]*)>(.*?)', html, re.DOTALL): tag = match.group(1) attrs_str = match.group(2) content = match.group(3) # 解析属性 attrs = self._parse_attributes(attrs_str) # 检查是否是 TUI 元素 if 'data-tui-type' in attrs or self._is_tui_element(tag, attrs): element = self._create_tui_element(tag, attrs, content) if element: elements.append(element) # 也解析自闭合标签 for match in re.finditer(r'<(\w+)([^/]*)/>', html): tag = match.group(1) attrs_str = match.group(2) attrs = self._parse_attributes(attrs_str) if 'data-tui-type' in attrs or self._is_tui_element(tag, attrs): element = self._create_tui_element(tag, attrs, "") if element: elements.append(element) return elements def _parse_attributes(self, attrs_str: str) -> Dict[str, Any]: """解析 HTML 属性""" attrs = {} for match in re.finditer(r'([\w-]+)=["\']([^"\']*)["\']', attrs_str): key = match.group(1) value = match.group(2) attrs[key] = value # 处理布尔属性 for match in re.finditer(r'([\w-]+)(?=\s|>|/>)', attrs_str): key = match.group(1) if key not in attrs: attrs[key] = True return attrs def _is_tui_element(self, tag: str, attrs: Dict) -> bool: """判断是否是 TUI 元素""" tui_tags = ['header', 'footer', 'nav', 'section', 'article', 'aside', 'main'] tui_attrs = ['data-tui-type', 'data-tui-action', 'data-tui-key', 'data-tui-layout'] return tag in tui_tags or any(attr in attrs for attr in tui_attrs) def _create_tui_element(self, tag: str, attrs: Dict, content: str) -> Optional[TUIElement]: """创建 TUI 元素""" # 清理 HTML 标签 text = re.sub(r'<[^>]+>', '', content).strip() text = html.unescape(text) # 获取样式 style = self._get_style_for_element(attrs) # 根据标签和属性创建元素 tui_type = attrs.get('data-tui-type', '').lower() if tag == 'button' or tui_type == 'button' or 'data-tui-key' in attrs: return TUIButton( id=attrs.get('id', ''), text=text or attrs.get('data-tui-key', 'Button'), classes=attrs.get('class', '').split(), style=style, width=self.width, action=attrs.get('data-tui-action', ''), target=attrs.get('href', attrs.get('data-tui-target', '')), shortcut=attrs.get('data-tui-key', '') ) elif tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header'] or tui_type == 'header': style.bold = True return TUILabel( id=attrs.get('id', ''), text=text, classes=attrs.get('class', '').split(), style=style, width=self.width, alignment="center" if tag == 'h1' else "left" ) elif tag == 'nav' or tui_type == 'nav': # 导航特殊处理 return None # 由 _extract_nav 处理 elif tag == 'hr' or tag == 'separator' or tui_type == 'separator': char = attrs.get('data-tui-char', '─') return TUISeparator(char=char, width=self.width) elif tag == 'ul' or tag == 'ol': items = [] for li_match in re.finditer(r']*>(.*?)', content, re.DOTALL): item_text = re.sub(r'<[^>]+>', '', li_match.group(1)).strip() items.append(html.unescape(item_text)) return TUIList(items=items, width=self.width, show_numbers=(tag == 'ol')) elif tag == 'footer' or tui_type == 'footer': style.dim = True return TUILabel( id=attrs.get('id', ''), text=text, classes=attrs.get('class', '').split(), style=style, width=self.width ) elif 'data-tui-layout' in attrs or tag in ['div', 'section', 'main', 'article']: layout_type = attrs.get('data-tui-layout', 'vertical') return TUILayout( id=attrs.get('id', ''), layout_type=layout_type, classes=attrs.get('class', '').split(), style=style, width=self.width ) else: # 默认标签 return TUILabel( id=attrs.get('id', ''), text=text, classes=attrs.get('class', '').split(), style=style, width=self.width ) def _get_style_for_element(self, attrs: Dict) -> TUIStyle: """获取元素样式""" style = TUIStyle() # 检查 class classes = attrs.get('class', '').split() for cls in classes: selector = f".{cls}" if selector in self.css_styles: base_style = self.css_styles[selector] style.fg_color = base_style.fg_color or style.fg_color style.bg_color = base_style.bg_color or style.bg_color style.bold = style.bold or base_style.bold style.dim = style.dim or base_style.dim style.underline = style.underline or base_style.underline # 检查 data-tui-style tui_style = attrs.get('data-tui-style', '') if 'bold' in tui_style: style.bold = True if 'dim' in tui_style: style.dim = True if 'underline' in tui_style: style.underline = True if 'reverse' in tui_style: style.reverse = True return style def _extract_nav(self, html: str) -> List[TUIElement]: """提取导航元素""" elements = [] for match in re.finditer(r']*>(.*?)', html, re.DOTALL | re.IGNORECASE): nav_html = match.group(1) for link_match in re.finditer(r']*href=["\']([^"\']*)["\'][^>]*>(.*?)', nav_html, re.DOTALL | re.IGNORECASE): href = link_match.group(1) link_text = re.sub(r'<[^>]+>', '', link_match.group(2)).strip() link_text = html.unescape(link_text) if hasattr(html, 'unescape') else link_text # 获取快捷键 attrs_str = link_match.group(0) shortcut = "" shortcut_match = re.search(r'data-tui-key=["\']([^"\']*)["\']', attrs_str) if shortcut_match: shortcut = shortcut_match.group(1) btn = TUIButton( text=f"{link_text}", target=href, shortcut=shortcut, action="navigate", width=self.width ) elements.append(btn) return elements def _extract_buttons(self, html: str) -> List[TUIElement]: """提取按钮""" elements = [] for match in re.finditer(r']*>(.*?)', html, re.DOTALL | re.IGNORECASE): attrs_str = match.group(0) text = re.sub(r'<[^>]+>', '', match.group(1)).strip() text = html.unescape(text) if hasattr(html, 'unescape') else text onclick = "" onclick_match = re.search(r'onclick=["\']([^"\']*)["\']', attrs_str) if onclick_match: onclick = onclick_match.group(1) btn = TUIButton( text=text or "Button", action=onclick, width=self.width ) elements.append(btn) return elements def get_keyboard_bindings(self) -> Dict[str, Dict]: """获取键盘绑定""" return self.keyboard_bindings class TUIRenderer: """TUI 渲染器""" def __init__(self, width: int = 80, height: int = 24): self.width = width self.height = height self.converter = HTMLToTUIConverter(width, height) self.screen_buffer: List[List[str]] = [] def render(self, html: str) -> str: """渲染 HTML 到终端字符串""" layout = self.converter.parse(html) return self.render_layout(layout) def render_layout(self, layout: TUILayout) -> str: """渲染布局""" self._init_buffer() self._render_element(layout, 0, 0) return self._buffer_to_string() def _init_buffer(self): """初始化缓冲区""" self.screen_buffer = [[' ' for _ in range(self.width)] for _ in range(self.height)] def _render_element(self, element: TUIElement, x: int, y: int): """渲染元素到缓冲区""" rendered = element.render() lines = rendered.split('\n') for i, line in enumerate(lines): if y + i >= self.height: break # 清理 ANSI 码计算实际长度 clean_line = re.sub(r'\x1b\[[0-9;]*m', '', line) for j, char in enumerate(line): if x + j >= self.width: break self.screen_buffer[y + i][x + j] = char def _buffer_to_string(self) -> str: """缓冲区转字符串""" return '\n'.join(''.join(row) for row in self.screen_buffer) def render_with_frame(self, html: str, title: str = "NebulaShell TUI") -> str: """渲染带边框的页面""" content = self.render(html) lines = content.split('\n') # 计算最大宽度 max_content_width = max(len(re.sub(r'\x1b\[[0-9;]*m', '', line)) for line in lines) if lines else 0 frame_width = min(max_content_width + 2, self.width) result = [] # 顶部 top = "╔" + "═" * (frame_width - 2) + "╗" if title: title_text = f" {title} " padding = (frame_width - 2 - len(title_text)) // 2 top = "╔" + "═" * padding + title_text + "═" * (frame_width - 2 - padding - len(title_text)) + "╗" result.append(top) # 内容 for line in lines: clean_len = len(re.sub(r'\x1b\[[0-9;]*m', '', line)) padding = frame_width - 2 - clean_len if padding > 0: line = line + " " * padding result.append(f"║ {line} ║") # 底部 result.append("╚" + "═" * (frame_width - 2) + "╝") return '\n'.join(result) class TUIInputHandler: """TUI 输入处理器 支持: - 键盘事件(包括功能键、方向键) - 鼠标事件(点击、移动) - 自定义键绑定 """ def __init__(self): self.key_bindings: Dict[str, Callable] = {} self.mouse_handlers: Dict[str, Callable] = {} self.mouse_x = 0 self.mouse_y = 0 self.running = True def bind_key(self, key: str, handler: Callable): """绑定按键""" self.key_bindings[key] = handler def bind_mouse(self, event: str, handler: Callable): """绑定鼠标事件""" self.mouse_handlers[event] = handler def handle_key(self, key: str) -> bool: """处理按键""" if key in self.key_bindings: self.key_bindings[key]() return True return False def handle_mouse(self, x: int, y: int, button: str = 'left') -> bool: """处理鼠标""" self.mouse_x = x self.mouse_y = y handler_key = f"{button}" if handler_key in self.mouse_handlers: self.mouse_handlers[handler_key](x, y) return True return False def read_key(self) -> str: """读取按键(原始模式)""" import sys import tty import termios fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(fd) char = sys.stdin.read(1) # 处理转义序列 if char == '\x1b': char += sys.stdin.read(2) return char finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) class TUICanvas: """TUI 画布""" def __init__(self, width: int = 80, height: int = 24): self.width = width self.height = height self.buffer = [[' ' for _ in range(width)] for _ in range(height)] self.renderer = TUIRenderer(width, height) def clear(self): """清屏""" self.buffer = [[' ' for _ in range(self.width)] for _ in range(self.height)] def draw_text(self, text: str, x: int, y: int, style: TUIStyle = None): """绘制文本""" if style: text = style.apply(text) lines = text.split('\n') for i, line in enumerate(lines): if y + i >= self.height: break for j, char in enumerate(line): if x + j >= self.width: break self.buffer[y + i][x + j] = char def draw_box(self, x: int, y: int, width: int, height: int, style: str = "single"): """绘制方框""" borders = getattr(BorderStyle, style.upper(), BorderStyle.SINGLE) # 顶边 self.draw_text(borders[0] + borders[1] * (width - 2) + borders[2], x, y) # 侧边 for i in range(1, height - 1): self.draw_text(f"{borders[3]}{' ' * (width - 2)}{borders[4]}", x, y + i) # 底边 self.draw_text(borders[5] + borders[6] * (width - 2) + borders[7], x, y + height - 1) def render(self) -> str: """渲染画布""" return '\n'.join(''.join(row) for row in self.buffer) def display(self): """显示到终端""" sys.stdout.write('\x1b[2J\x1b[H') # 清屏 sys.stdout.write(self.render()) sys.stdout.flush() class TUIEventManager: """TUI 事件管理器""" def __init__(self): self.events: Dict[str, List[Callable]] = {} def on(self, event: str, handler: Callable): """注册事件处理器""" if event not in self.events: self.events[event] = [] self.events[event].append(handler) def emit(self, event: str, *args, **kwargs): """触发事件""" if event in self.events: for handler in self.events[event]: handler(*args, **kwargs) class TUIManager: """TUI 管理器 - 核心管理类 功能: - 页面管理 - 渲染控制 - 事件循环 - 输入处理 """ _instance: Optional['TUIManager'] = None def __init__(self, width: int = 80, height: int = 24): self.width = width self.height = height self.canvas = TUICanvas(width, height) self.renderer = TUIRenderer(width, height) self.converter = HTMLToTUIConverter(width, height) self.input_handler = TUIInputHandler() self.event_manager = TUIEventManager() self.pages: Dict[str, str] = {} # path -> html self.current_page = "" self.running = False self.selected_index = 0 self.nav_items: List[Dict] = [] @classmethod def get_instance(cls, width: int = 80, height: int = 24) -> 'TUIManager': """获取单例实例""" if cls._instance is None: cls._instance = TUIManager(width, height) return cls._instance def load_page(self, path: str, html_content: str): """加载页面""" self.pages[path] = html_content self.current_page = path def navigate(self, path: str): """导航到页面""" if path in self.pages: self.current_page = path self.render_current() else: self.show_error(f"Page not found: {path}") def render_page(self, path: str = None) -> str: """渲染指定页面,返回终端文本(不写入画布)""" path = path or self.current_page if not path or path not in self.pages: return "" html = self.pages[path] return self.renderer.render_with_frame(html, title=f"NebulaShell - {path}") def render_current(self): """渲染当前页面""" if not self.current_page or self.current_page not in self.pages: return html = self.pages[self.current_page] output = self.renderer.render_with_frame(html, title=f"NebulaShell - {self.current_page}") self.canvas.clear() self.canvas.draw_text(output, 0, 0) self.canvas.display() def show_error(self, message: str): """显示错误""" error_html = f"""

❌ 错误

{message}

按任意键返回

""" self.load_page("/error", error_html) self.render_current() def setup_default_bindings(self): """设置默认键绑定""" self.input_handler.bind_key('q', self.quit) self.input_handler.bind_key('Q', self.quit) self.input_handler.bind_key('\x03', self.quit) # Ctrl+C self.input_handler.bind_key('\x04', self.quit) # Ctrl+D def setup_keyboard_navigation(self): """从当前页面提取键盘绑定""" if self.current_page not in self.pages: return html = self.pages[self.current_page] converter = HTMLToTUIConverter(self.width, self.height) converter.parse(html) for key, config in converter.get_keyboard_bindings().items(): action = config.get('action', '') target = config.get('target', '') if action == 'navigate' and target: self.input_handler.bind_key(key, lambda t=target: self.navigate(t)) elif action == 'quit': self.input_handler.bind_key(key, self.quit) elif action == 'refresh': self.input_handler.bind_key(key, self.render_current) def run_event_loop(self): """运行事件循环""" self.running = True self.setup_default_bindings() while self.running: self.setup_keyboard_navigation() key = self.input_handler.read_key() self.input_handler.handle_key(key) def quit(self): """退出""" self.running = False def start(self): """启动 TUI""" if self.current_page: self.render_current() self.run_event_loop() # 全局实例 _tui_manager_instance: Optional[TUIManager] = None def get_tui_manager(width: int = 80, height: int = 24) -> TUIManager: """获取 TUI 管理器实例""" global _tui_manager_instance if _tui_manager_instance is None: _tui_manager_instance = TUIManager(width, height) return _tui_manager_instance