576 lines
18 KiB
Python
576 lines
18 KiB
Python
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):
|
|
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'
|
|
|
|
@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'
|
|
|
|
@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]:
|
|
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:
|
|
return getattr(cls, name.upper(), cls.SINGLE)
|
|
|
|
|
|
@dataclass
|
|
class TUIColor:
|
|
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" vertical_align: str = "top"
|
|
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" overflow_y: str = "clip"
|
|
|
|
display: str = "block" visibility: str = "visible"
|
|
cursor: str = "default"
|
|
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:
|
|
merged = TUIStyle()
|
|
for attr in self.__dataclass_fields__:
|
|
self_val = getattr(self, attr)
|
|
other_val = getattr(other, attr)
|
|
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':
|
|
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:
|
|
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.x, self.y, self.width, self.height)
|
|
|
|
|
|
@dataclass
|
|
class TUIButton(TUIElement):
|
|
alignment: str = "left"
|
|
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):
|
|
layout_type: str = "vertical" 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):
|
|
char: str = "─"
|
|
|
|
def render(self) -> str:
|
|
return self.char * self.width
|
|
|
|
|
|
@dataclass
|
|
class TUIProgressBar(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:
|
|
|
|
COLOR_MAP = {
|
|
' ' ' ' ' ' ' ' ' ' '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 = {
|
|
' ' ' ' ' ' ' ' '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:
|
|
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):
|
|
for match in re.finditer(r'<style[^>]*type=["\']text/x-tui-css["\'][^>]*>(.*?)</style>', html, re.DOTALL):
|
|
css = match.group(1)
|
|
for rule_match in re.finditer(r'([. 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:
|
|
elements = []
|
|
|
|
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)
|
|
|
|
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]:
|
|
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]:
|
|
style = TUIStyle()
|
|
|
|
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
|
|
|
|
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'<button[^>]*>(.*?)</button>', 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]:
|
|
|
|
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:
|
|
self._init_buffer()
|
|
self._render_element(layout, 0, 0)
|
|
return self._buffer_to_string()
|
|
|
|
def _init_buffer(self):
|
|
rendered = element.render()
|
|
lines = rendered.split('\n')
|
|
|
|
for i, line in enumerate(lines):
|
|
if y + i >= self.height:
|
|
break
|
|
|
|
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:
|
|
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:
|
|
|
|
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.mouse_handlers[event] = handler
|
|
|
|
def handle_key(self, key: str) -> 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:
|
|
|
|
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):
|
|
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"):
|
|
return '\n'.join(''.join(row) for row in self.buffer)
|
|
|
|
def display(self):
|
|
|
|
def __init__(self):
|
|
self.events: Dict[str, List[Callable]] = {}
|
|
|
|
def on(self, event: str, handler: Callable):
|
|
if event in self.events:
|
|
for handler in self.events[event]:
|
|
handler(*args, **kwargs)
|
|
|
|
|
|
class TUIManager:
|
|
|
|
_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] = {} 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':
|
|
self.pages[path] = html_content
|
|
self.current_page = path
|
|
|
|
def navigate(self, path: 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):
|
|
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):
|
|
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 = False
|
|
|
|
def start(self):
|
|
global _tui_manager_instance
|
|
if _tui_manager_instance is None:
|
|
_tui_manager_instance = TUIManager(width, height)
|
|
return _tui_manager_instance
|