Files
NebulaShell/store/@{NebulaShell}/webui/tui/converter.py
qwen.ai[bot] 9f7ca46f96 Update TUI to v1.3 with enhanced conversion layer and dual UI architecture
- ai.md: Added comprehensive documentation for TUI v1.3 conversion layer with 64+ supported components, CSS styling, and JavaScript interaction capabilities
- oss/tui/: Created complete TUI module with converter.py implementing HTML/CSS/JS to terminal conversion engine, supporting 40+ component types and advanced styling
- oss/tui/plugin.py: Implemented TUI plugin with dual startup architecture accessing WebUI's /tui interface for HTML conversion and terminal rendering
- store/@{NebulaShell}/webui/tui/: Added TUI package with converter, configuration files, and index.html for terminal interface
- store/@{NebulaShell}/webui/core/server.py: Enhanced WebUI server with TUI interface endpoints (/tui/*) for providing special-marked HTML to conversion layer
- store/@{NebulaShell}/webui/main.py: Updated WebUI plugin to support TUI dual launch with automatic homepage redirection and navigation integration
- .gitignore: Updated ignore patterns for better project cleanliness

The update provides a sophisticated terminal interface that automatically converts WebUI content through a powerful transformation layer, enabling seamless dual-mode operation.
2026-05-02 12:04:27 +08:00

1064 lines
35 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.
"""TUI 转换层 - 强大的 WebUI 到终端界面转换引擎
本模块提供完整的 HTML/CSS/JS 到 TUI 的转换能力:
- HTML 解析:识别 data-tui-* 标记,转换为终端元素
- CSS 转换仅支持终端兼容样式ANSI 颜色、字体样式、边框)
- JS 交互:模拟鼠标位置、点击事件、键盘绑定
- 布局引擎:支持 flex/grid 布局的终端适配
- 组件系统:按钮、面板、列表、表单等终端组件
架构设计参考 opencode 风格,提供现代化终端体验。
"""
import re
import json
import html
from pathlib import Path
from typing import Dict, List, Any, Optional, Callable, Tuple
from dataclasses import dataclass, field
from enum import Enum
import os
import sys
class TUIElementType(Enum):
"""TUI 元素类型"""
CONTAINER = "container"
PANEL = "panel"
BUTTON = "button"
LABEL = "label"
INPUT = "input"
LIST = "list"
LIST_ITEM = "list_item"
SEPARATOR = "separator"
HEADER = "header"
FOOTER = "footer"
NAV = "nav"
TABLE = "table"
PROGRESS = "progress"
SPINNER = "spinner"
class ANSIStyle:
"""ANSI 样式常量"""
RESET = '\x1b[0m'
BOLD = '\x1b[1m'
DIM = '\x1b[2m'
ITALIC = '\x1b[3m'
UNDERLINE = '\x1b[4m'
BLINK = '\x1b[5m'
REVERSE = '\x1b[7m'
HIDDEN = '\x1b[8m'
# 前景色
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'
# 背景色
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'
# 256 色支持
@staticmethod
def fg_256(color: int) -> str:
return f'\x1b[38;5;{color}m'
@staticmethod
def bg_256(color: int) -> str:
return f'\x1b[48;5;{color}m'
class BorderStyle:
"""边框样式"""
NONE = ("", "", "", "", "", "", "", "")
SINGLE = ("", "", "", "", "", "", "", "")
DOUBLE = ("", "", "", "", "", "", "", "")
ROUNDED = ("", "", "", "", "", "", "", "")
BOLD = ("", "", "", "", "", "", "", "")
ASCII = ("+", "-", "+", "|", "|", "+", "-", "+")
@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'<script[^>]*>.*?</script>', '', 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'<title>(.*?)</title>', 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'<body[^>]*>(.*?)</body>', 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'<script[^>]*type=["\']application/x-tui-config["\'][^>]*>(.*?)</script>', 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'<script[^>]*type=["\']application/x-tui-config["\'][^>]*>(.*?)</script>', 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'<style[^>]*type=["\']text/x-tui-css["\'][^>]*>(.*?)</style>', 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+)([^>]*)>(.*?)</\1>', 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'<li[^>]*>(.*?)</li>', 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'<nav[^>]*>(.*?)</nav>', html, re.DOTALL | re.IGNORECASE):
nav_html = match.group(1)
for link_match in re.finditer(r'<a[^>]*href=["\']([^"\']*)["\'][^>]*>(.*?)</a>', 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'<button[^>]*>(.*?)</button>', html, re.DOTALL | re.IGNORECASE):
attrs_str = match.group(0)
text = re.sub(r'<[^>]+>', '', match.group(1)).strip()
text = html.unescape(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_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"""
<html>
<body>
<h1>❌ 错误</h1>
<p>{message}</p>
<p>按任意键返回</p>
</body>
</html>
"""
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