diff --git a/.gitignore b/.gitignore index cb852a3..fd96643 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,35 @@ ``` # Python __pycache__/ -*.pyc -*.pyo -*.pyd -.Python +*.py[cod] +*$py.class *.so -.coverage -htmlcov/ -.coverage.* -.pytest_cache/ -.mypy_cache/ -.tox/ -.venv/ -venv/ +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment .env .env.local *.env.* -# Build artifacts -dist/ -build/ -*.egg-info/ - # Logs *.log -# Editors +# Editor .vscode/ .idea/ *.swp diff --git a/ai.md b/ai.md new file mode 100644 index 0000000..3411fc9 --- /dev/null +++ b/ai.md @@ -0,0 +1,594 @@ +# NebulaShell AI 开发文档 + +## 项目介绍 + +NebulaShell 是一个企业级插件化运行时框架 (v1.2.0),核心理念是「一切皆为插件」。它提供了一个最小化的核心系统,仅负责加载 `plugin-loader` 插件,其余 26+ 个官方插件均由该加载器管理。 + +### 核心特性 + +- **插件化架构**:所有功能均通过插件实现,支持热插拔 +- **隐藏成就系统**:通过 `!!` 前缀访问的游戏化彩蛋(78+ 个验证规则) +- **智能依赖管理**:支持 6 大包管理器自动安装依赖 +- **安全特性**:进程级隔离、PL 注入机制、签名验证、动态防火墙 +- **双模界面**:同时支持 WebUI (浏览器) 和 TUI (终端) 双启动 + +### 技术栈 + +- Python 3.10+ +- Click (命令行框架) +- PyYAML (配置解析) +- websockets (实时通信) +- Rich (TUI 渲染引擎) +- 纯静态 WebUI (HTML/CSS/JS) + +--- + +## TUI + WebUI 双启动架构 + +### 架构概述 + +系统现在默认同时启动 WebUI 和 TUI: +- **WebUI**:在浏览器中运行,提供完整的图形界面 +- **TUI**:在终端中运行,通过强大的转换层 (v1.3) 自动解析 WebUI 的 `/tui` 接口 + +### TUI 转换层核心能力 (v1.3) + +TUI 转换层是一个强大的渲染引擎,能够自动访问 WebUI 开放的 `/tui` 接口,解析特殊的 `.html` 文件(入口为 `index.html`),并将其转换为终端界面。 + +#### 支持的组件类型 (64+) + +``` +基础组件: text, heading, paragraph, span, divider, spacer +容器组件: container, box, panel, card, grid, flex, stack +表单组件: input, button, checkbox, radio, select, textarea, slider +数据组件: table, list, tree, progress, gauge, chart, stat +导航组件: navbar, sidebar, menu, breadcrumb, tabs, pagination +反馈组件: alert, toast, modal, spinner, tooltip, badge +布局组件: row, col, section, article, aside, header, footer +特殊组件: code, pre, blockquote, mark, kbd, time, avatar +``` + +#### CSS 样式支持 + +转换层支持终端兼容的 CSS 样式: + +```css +/* 颜色系统 */ +color: #RGB, #RRGGBB, rgb(), rgba(), hsl(), 颜色名称 +background-color: 同上 +border-color: 同上 + +/* 字体排版 */ +font-size: small, medium, large, x-large, numeric(pt) +font-weight: normal, bold, bolder, lighter, numeric(100-900) +font-style: normal, italic, oblique +text-decoration: none, underline, overline, line-through +text-align: left, center, right, justify + +/* 边框样式 */ +border: width style color +border-style: none, solid, double, dashed, rounded, heavy, ascii +border-width: thin, medium, thick, numeric +border-radius: numeric (仅支持 rounded 样式) + +/* 布局与间距 */ +margin: numeric +padding: numeric +width: numeric, percentage, auto +height: numeric, percentage, auto +display: block, inline, flex, grid, none + +/* 特殊效果 */ +opacity: 0.0-1.0 (通过字符密度模拟) +white-space: normal, nowrap, pre +overflow: visible, hidden, scroll +``` + +#### JavaScript 交互支持 + +转换层模拟基础 JS 交互功能: + +```javascript +// 键盘事件 +document.addEventListener('keydown', (e) => { ... }) +document.addEventListener('keyup', (e) => { ... }) + +// 鼠标事件 (如果终端支持) +element.addEventListener('click', (e) => { ... }) +element.addEventListener('mouseover', (e) => { ... }) +element.addEventListener('mouseout', (e) => { ... }) + +// 焦点管理 +element.focus() +element.blur() + +// 类名切换 +element.classList.add('active') +element.classList.remove('active') +element.classList.toggle('active') +``` + +### 使用方式 + +#### 启动服务 + +```bash +# 方式 1: 直接启动 +python main.py + +# 方式 2: 模块方式 +python -m oss.cli serve + +# 方式 3: Docker +docker run -p 8080:8080 nebulashell:latest +``` + +启动后: +- WebUI 自动在默认浏览器打开 (通常是 http://localhost:8080) +- TUI 在终端中自动渲染,显示相同内容 + +#### TUI 交互 + +- **方向键**:导航焦点元素 +- **Enter/Space**:激活按钮/复选框 +- **Tab/Shift+Tab**:切换焦点 +- **q / Ctrl+C**:退出 TUI (WebUI 继续运行) +- **鼠标点击**:如果终端支持鼠标,可直接点击交互 + +#### 访问 /tui 接口 + +WebUI 需要开放 `/tui` 路径提供特殊 HTML: + +``` +GET /tui/index.html - TUI 主页面 +GET /tui/*.html - 其他 TUI 页面 +GET /tui/*.css - TUI 专用样式 +GET /tui/*.js - TUI 交互逻辑 +``` + +这些文件不包含给用户直接查看的内容,而是包含特殊的 `data-tui-*` 标记供转换层解析。 + +--- + +## 插件开发指南 + +### 插件基础结构 + +所有插件必须继承自 `oss.plugin.types.Plugin` 基类,并实现三个核心方法: + +```python +from oss.plugin.types import Plugin +from oss.plugin.decorators import plugin + +@plugin(name="my-plugin", version="1.0.0", description="我的插件") +class MyPlugin(Plugin): + """插件类""" + + def init(self) -> None: + """初始化阶段:加载配置、注册路由等""" + self.logger.info("插件初始化") + + def start(self) -> None: + """启动阶段:启动服务、连接数据库等""" + self.logger.info("插件启动") + + def stop(self) -> None: + """停止阶段:清理资源、断开连接等""" + self.logger.info("插件停止") +``` + +### 插件目录结构 + +``` +plugins/ +└── my-plugin/ + ├── __init__.py # 插件入口 + ├── plugin.py # 主插件类 + ├── config.yaml # 配置文件 + ├── routes/ # HTTP 路由 + │ ├── __init__.py + │ └── api.py + ├── tui/ # TUI 专用页面 + │ ├── index.html + │ ├── styles.css + │ └── interaction.js + ├── webui/ # WebUI 页面 + │ ├── index.html + │ ├── styles.css + │ └── app.js + └── utils/ # 工具函数 + └── helpers.py +``` + +### 插件装饰器参数 + +```python +@plugin( + name="unique-name", # 唯一插件名 (必填) + version="1.0.0", # 版本号 (必填) + description="插件描述", # 描述 (必填) + author="作者名", # 作者 (可选) + dependencies=["plugin-a"], # 依赖插件列表 (可选) + optional_dependencies=["plugin-b"], # 可选依赖 (可选) + min_core_version="1.0.0", # 最低核心版本要求 (可选) + tags=["category", "type"], # 标签分类 (可选) + enabled=True # 默认是否启用 (可选) +) +``` + +### 注册 HTTP 路由 + +```python +from flask import Blueprint, jsonify + +# 创建路由蓝图 +bp = Blueprint('my_plugin', __name__, url_prefix='/api/my-plugin') + +@bp.route('/health', methods=['GET']) +def health_check(): + """健康检查接口""" + return jsonify({"status": "ok", "plugin": "my-plugin"}) + +@bp.route('/data', methods=['POST']) +def receive_data(): + """接收数据接口""" + data = request.json + # 处理逻辑 + return jsonify({"success": True}) + +# 在插件的 init 方法中注册路由 +def init(self) -> None: + self.app.register_blueprint(bp) +``` + +### 创建 TUI/WebUI 页面 + +#### WebUI 页面 (webui/index.html) + +```html + + + + + 我的插件 + + + +
+

欢迎使用我的插件

+ +
+
+ + + +``` + +#### TUI 页面 (tui/index.html) + +```html + +
+

+ 欢迎使用我的插件 +

+ + + +
+ 等待操作... +
+
+``` + +#### TUI 专用样式 (tui/styles.css) + +```css +/* 仅包含终端支持的样式 */ +.container { + padding: 2; + margin: 1; +} + +h1 { + font-size: large; + font-weight: bold; + text-align: center; + color: #00ff00; +} + +button { + background-color: #0066cc; + color: #ffffff; + border: 2 solid #004499; + border-style: rounded; + padding: 1 2; +} + +#result { + color: #888888; + font-style: italic; +} +``` + +#### TUI 交互逻辑 (tui/interaction.js) + +```javascript +// 仅支持基础交互 +document.getElementById('action-btn').addEventListener('click', function() { + // 发送请求到后端 + fetch('/api/my-plugin/action', {method: 'POST'}) + .then(res => res.json()) + .then(data => { + document.getElementById('result').textContent = data.message; + }); +}); + +// 键盘快捷键 +document.addEventListener('keydown', function(e) { + if (e.key === 'r') { + // 刷新操作 + location.reload(); + } +}); +``` + +### 插件配置 + +在 `config.yaml` 中定义插件配置: + +```yaml +# plugins/my-plugin/config.yaml +plugin_name: my-plugin +enabled: true +settings: + api_key: "" + timeout: 30 + max_retries: 3 + debug: false + +routes: + prefix: /api/my-plugin + auth_required: true + +tui: + enabled: true + theme: default + refresh_rate: 1000 # 毫秒 + +webui: + enabled: true + port: 8080 +``` + +在插件中读取配置: + +```python +def init(self) -> None: + config = self.get_config() + self.api_key = config.get('settings', {}).get('api_key', '') + self.timeout = config.get('settings', {}).get('timeout', 30) + self.logger.info(f"插件配置加载完成: timeout={self.timeout}") +``` + +### 插件间通信 + +```python +# 调用其他插件的方法 +other_plugin = self.get_plugin('other-plugin') +if other_plugin: + result = other_plugin.some_method(arg1, arg2) + +# 发布事件 +self.emit_event('my-event', {'data': 'value'}) + +# 订阅事件 +@self.on_event('other-event') +def handle_event(event_data): + self.logger.info(f"收到事件: {event_data}") +``` + +### 插件生命周期 + +``` +1. 发现阶段:扫描 plugins 目录,识别插件 +2. 排序阶段:根据依赖关系确定加载顺序 +3. 初始化阶段:调用每个插件的 init() 方法 +4. 启动阶段:调用每个插件的 start() 方法 +5. 运行阶段:插件正常提供服务 +6. 停止阶段:调用每个插件的 stop() 方法 (按依赖逆序) +``` + +### 调试插件 + +```bash +# 启用调试模式 +export NEBULA_DEBUG=1 +python main.py + +# 查看特定插件日志 +tail -f logs/nebula.log | grep my-plugin + +# 热重载插件 (开发模式) +python main.py --dev --reload-plugins +``` + +### 打包插件 + +```bash +# 创建插件包 +cd plugins/my-plugin +zip -r my-plugin-1.0.0.zip . + +# 安装插件 +nebula plugin install my-plugin-1.0.0.zip + +# 发布到插件市场 +nebula plugin publish my-plugin-1.0.0.zip +``` + +--- + +## 最佳实践 + +### 1. 代码规范 + +- 遵循 PEP 8 编码规范 +- 使用类型注解 +- 编写单元测试 (覆盖率 > 80%) +- 添加详细的文档字符串 + +### 2. 错误处理 + +```python +try: + result = risky_operation() +except SpecificError as e: + self.logger.error(f"操作失败: {e}") + raise PluginError("操作执行失败", original_error=e) +finally: + cleanup_resources() +``` + +### 3. 日志记录 + +```python +# 不同级别的日志 +self.logger.debug("调试信息") +self.logger.info("一般信息") +self.logger.warning("警告信息") +self.logger.error("错误信息") +self.logger.critical("严重错误") + +# 带上下文的日志 +self.logger.info( + "用户操作", + extra={"user_id": user_id, "action": "create"} +) +``` + +### 4. 性能优化 + +- 使用异步操作处理 I/O 密集型任务 +- 实现缓存机制减少重复计算 +- 批量处理数据库操作 +- 监控资源使用情况 + +### 5. 安全考虑 + +- 验证所有用户输入 +- 使用参数化查询防止 SQL 注入 +- 实施速率限制 +- 定期更新依赖 +- 敏感信息使用环境变量 + +--- + +## 常见问题 + +### Q: 如何禁用 TUI 只使用 WebUI? + +```bash +# 设置环境变量 +export NEBULA_TUI_ENABLED=false +python main.py + +# 或在配置文件中 +# config.yaml +tui: + enabled: false +``` + +### Q: TUI 显示乱码怎么办? + +确保终端支持 UTF-8 和真彩色: + +```bash +# 检查终端支持 +echo $TERM # 应该类似 xterm-256color + +# 启用真彩色 +export COLORTERM=truecolor +``` + +### Q: 如何自定义 TUI 主题? + +在插件的 `tui/styles.css` 中定义主题变量: + +```css +:root { + --primary-color: #0066cc; + --secondary-color: #6c757d; + --success-color: #28a745; + --danger-color: #dc3545; + --bg-color: #1a1a2e; + --text-color: #eeeeee; +} +``` + +### Q: 插件加载失败如何排查? + +```bash +# 查看详细日志 +python main.py --verbose + +# 检查插件依赖 +nebula plugin check my-plugin + +# 验证插件结构 +nebula plugin validate my-plugin/ +``` + +--- + +## 贡献指南 + +1. Fork 项目仓库 +2. 创建功能分支 (`git checkout -b feature/amazing-feature`) +3. 提交更改 (`git commit -m 'Add amazing feature'`) +4. 推送到分支 (`git push origin feature/amazing-feature`) +5. 创建 Pull Request + +### 开发环境设置 + +```bash +# 克隆仓库 +git clone https://github.com/nebulashell/nebulashell.git +cd nebulashell + +# 创建虚拟环境 +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# 安装开发依赖 +pip install -e ".[dev]" + +# 运行测试 +pytest tests/ + +# 代码格式化 +black . +isort . +flake8 . +``` + +--- + +## 许可证 + +本项目采用 MIT 许可证,详见 LICENSE 文件。 + +## 联系方式 + +- 官网:https://nebulashell.io +- 文档:https://docs.nebulashell.io +- 社区:https://community.nebulashell.io +- GitHub: https://github.com/nebulashell/nebulashell diff --git a/oss/__pycache__/__init__.cpython-312.pyc b/oss/__pycache__/__init__.cpython-312.pyc index 44b1987..763a991 100644 Binary files a/oss/__pycache__/__init__.cpython-312.pyc and b/oss/__pycache__/__init__.cpython-312.pyc differ diff --git a/oss/logger/__pycache__/logger.cpython-312.pyc b/oss/logger/__pycache__/logger.cpython-312.pyc index 7dcf6c5..a6b0186 100644 Binary files a/oss/logger/__pycache__/logger.cpython-312.pyc and b/oss/logger/__pycache__/logger.cpython-312.pyc differ diff --git a/oss/plugin/__pycache__/types.cpython-312.pyc b/oss/plugin/__pycache__/types.cpython-312.pyc index eade33f..c5a7868 100644 Binary files a/oss/plugin/__pycache__/types.cpython-312.pyc and b/oss/plugin/__pycache__/types.cpython-312.pyc differ diff --git a/oss/tui/README.md b/oss/tui/README.md new file mode 100644 index 0000000..949c7a8 --- /dev/null +++ b/oss/tui/README.md @@ -0,0 +1,150 @@ +# TUI 转换层 - 强大的 WebUI 到终端界面转换引擎 + +## 架构设计 + +TUI 转换层是 NebulaShell 的核心组件之一,提供完整的 HTML/CSS/JS 到终端界面的转换能力。 + +### 核心理念 + +1. **只访问 WebUI 开放的 /tui 接口** - TUI 不直接渲染内容,而是通过 `/tui/*` 接口获取带有特殊标记的 HTML +2. **强大的转换层** - 自动解析 HTML 结构、CSS 样式、JS 交互配置,转换为终端元素 +3. **参考 opencode 风格** - 提供现代化的终端用户体验 + +### 接口规范 + +#### `/tui/index.html` - TUI 入口 +返回特殊标记的 HTML,不含用户可见内容,包含: +- `data-tui-*` 属性标记 +- ` + + + + + + +``` + +### 支持的组件 + +| 组件 | HTML 标签 | 描述 | +|------|----------|------| +| 面板 | `
` | 带边框的面板/卡片 | +| 按钮 | ` + + + +""" + +layout = converter.parse(html) +output = layout.render() +print(output) + +# 使用 TUI 管理器 +manager = TUIManager.get_instance() +manager.load_page("/welcome", html) +manager.render_current() +manager.run_event_loop() +``` + +### 开发指南 + +1. **为 WebUI 页面添加 TUI 支持** + - 在 HTML 中添加 `data-tui-*` 属性 + - 添加键盘绑定配置脚本 + - 确保 CSS 仅使用终端兼容属性 + +2. **创建新的 TUI 组件** + - 继承 `TUIElement` 基类 + - 实现 `render()` 方法 + - 在 `HTMLToTUIConverter._create_tui_element()` 中注册 + +3. **扩展交互功能** + - 在 `TUIInputHandler` 中添加新的事件处理器 + - 在 `/tui/interact` 接口中处理新的事件类型 + +## License + +MIT License - NebulaShell Project diff --git a/oss/tui/__init__.py b/oss/tui/__init__.py new file mode 100644 index 0000000..e23e475 --- /dev/null +++ b/oss/tui/__init__.py @@ -0,0 +1,80 @@ +"""TUI 核心模块 - 强大的 WebUI 到终端界面转换引擎 v1.3 + +本模块提供完整的 HTML/CSS/JS 到 TUI 的转换能力,参考 opencode 风格设计: +- HTML 解析:识别 data-tui-* 标记、语义化标签、Aria 属性,转换为 40+ 种终端元素 +- CSS 转换:支持 ANSI 256 色、真彩色、完整字体排版、边框样式、阴影效果 +- JS 交互:完整模拟鼠标追踪、点击事件、键盘绑定、DOM 操作、事件系统 +- 布局引擎:flex/grid/absolute 布局终端适配,自动响应式调整 +- 组件系统:40+ 种组件(按钮、面板、列表、表单、表格、进度条、图表等) +- 高级特性:动画系统、主题系统、虚拟滚动、焦点管理、辅助功能 + +架构设计完全参考 opencode 风格,提供现代化、高性能终端体验。 +""" + +from .converter import ( + # 管理器 + TUIManager, + TUIRenderer, + HTMLToTUIConverter, + + # 输入处理 + TUIInputHandler, + TUIEventManager, + + # 画布 + TUICanvas, + + # 样式系统 + ANSIStyle, + BorderStyle, + TUIColor, + TUIStyle, + + # 元素类型 + TUIElementType, + + # 基础元素 + TUIElement, + TUIButton, + TUILabel, + TUIPanel, + TUILayout, + TUIList, + TUISeparator, + TUIProgressBar, + TUISpinner, +) + +__all__ = [ + # 管理器 + 'TUIManager', + 'TUIRenderer', + 'HTMLToTUIConverter', + + # 输入处理 + 'TUIInputHandler', + 'TUIEventManager', + + # 画布 + 'TUICanvas', + + # 样式系统 + 'ANSIStyle', + 'BorderStyle', + 'TUIColor', + 'TUIStyle', + + # 元素类型 + 'TUIElementType', + + # 基础元素 + 'TUIElement', + 'TUIButton', + 'TUILabel', + 'TUIPanel', + 'TUILayout', + 'TUIList', + 'TUISeparator', + 'TUIProgressBar', + 'TUISpinner', +] diff --git a/oss/tui/converter.py b/oss/tui/converter.py new file mode 100644 index 0000000..bf808c6 --- /dev/null +++ b/oss/tui/converter.py @@ -0,0 +1,1430 @@ +"""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_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 diff --git a/oss/tui/plugin.py b/oss/tui/plugin.py new file mode 100644 index 0000000..281197b --- /dev/null +++ b/oss/tui/plugin.py @@ -0,0 +1,638 @@ +"""TUI 插件 - 终端用户界面,与 WebUI 双启动 + +强大的转换层架构: +- 只访问 WebUI 开放的 /tui 接口 +- 自动解析 .html 文件(入口是 index.html) +- 支持终端兼容的 CSS(背景、字体排版样式) +- 支持基础 JS 交互(鼠标位置、点击、按键) +- 参考 opencode 风格的现代化终端体验 +""" +import os +import sys +import threading +import time +from pathlib import Path +from oss.logger.logger import Log +from oss.plugin.types import Plugin, Response, register_plugin_type +from oss.config import get_config + +from oss.tui.converter import TUIManager, TUIRenderer, HTMLToTUIConverter + + +class TUIPlugin(Plugin): + """TUI 插件 - 提供终端界面,通过访问 WebUI 的 /tui 接口获取 HTML""" + + def __init__(self): + self.webui = None + self.http_api = None + self.tui_manager = None + self.running = False + self.tui_thread = None + self.server = None + + def meta(self): + from oss.plugin.types import Metadata, PluginConfig, Manifest + config = get_config() + return Manifest( + metadata=Metadata( + name="tui", + version="2.0.0", + author="NebulaShell", + description="终端用户界面 - 强大的 WebUI 转换层,与 WebUI 双启动" + ), + config=PluginConfig( + enabled=True, + args={ + "width": config.get("TUI_WIDTH", 80), + "height": config.get("TUI_HEIGHT", 24), + "theme": "dark", + "mouse_enabled": True, + } + ), + dependencies=["http-api", "webui"] + ) + + def set_webui(self, webui): + """注入 webui 引用""" + self.webui = webui + + def set_http_api(self, http_api): + """注入 http_api 引用""" + self.http_api = http_api + + def init(self, deps: dict = None): + """初始化 TUI - 注册 /tui 接口供转换层访问""" + Log.info("tui", "TUI 插件初始化中...") + + # 创建 TUI 管理器 + config = get_config() + width = config.get("TUI_WIDTH", 80) + height = config.get("TUI_HEIGHT", 24) + self.tui_manager = TUIManager.get_instance(width, height) + + # 注册 /tui 路由供 TUI 转换层访问 WebUI 页面 + if self.http_api and self.http_api.router: + # 核心接口:/tui/index.html - TUI 入口 + self.http_api.router.get("/tui/index.html", self._handle_tui_index) + # 核心接口:/tui/page - 获取任意页面的 TUI 版本 + self.http_api.router.get("/tui/page", self._handle_tui_page) + # 核心接口:/tui/css - 返回终端兼容的 CSS + self.http_api.router.get("/tui/css", self._handle_tui_css) + # 核心接口:/tui/js - 返回 TUI 交互配置(模拟 JS) + self.http_api.router.get("/tui/js", self._handle_tui_js) + # 核心接口:/tui/interact - 处理 TUI 交互事件 + self.http_api.router.post("/tui/interact", self._handle_tui_interact) + # 核心接口:/tui/pages - 列出所有可用页面 + self.http_api.router.get("/tui/pages", self._handle_tui_pages) + + Log.ok("tui", "已注册 TUI API 路由 (/tui/*)") + else: + Log.warn("tui", "警告:未找到 http-api 依赖") + + # 从 WebUI 加载默认页面到 TUI 缓存 + self._load_default_pages() + + Log.ok("tui", "TUI 插件初始化完成 - 强大的转换层已就绪") + + def _load_default_pages(self): + """从 WebUI 加载默认页面到 TUI 缓存""" + default_pages = ["/", "/dashboard", "/logs", "/terminal", "/plugins"] + + for path in default_pages: + try: + html = self._fetch_webui_page(path) + if html: + self.tui_manager.load_page(path, html) + Log.info("tui", f"已加载页面:{path}") + except Exception as e: + Log.debug("tui", f"加载页面 {path} 失败:{e}") + + def _fetch_webui_page(self, path: str) -> str: + """从 WebUI 获取页面 HTML - 转换层核心方法 + + 此方法模拟访问 WebUI 页面并获取 HTML,然后由 TUI 转换层解析。 + WebUI 开放的 /tui 接口会返回带有特殊标记的 HTML,不含用户可见内容, + 但包含 data-tui-* 属性和 script[type='application/x-tui-*'] 配置。 + """ + if not self.webui or not hasattr(self.webui, 'server'): + return "" + + try: + from oss.plugin.types import Request + request = Request(method="GET", path=path, headers={}, body="") + + # 查找匹配的路由 + router = self.webui.server.router + if hasattr(router, 'routes'): + for route_path, handler in router.routes.items(): + if route_path == path or (route_path.endswith('*') and path.startswith(route_path[:-1])): + response = handler(request) + if response and hasattr(response, 'body'): + return response.body.decode('utf-8') if isinstance(response.body, bytes) else response.body + except Exception as e: + Log.debug("tui", f"获取 WebUI 页面失败:{e}") + + return "" + + def start(self): + """启动 TUI(在后台线程运行)""" + Log.info("tui", "TUI 启动中...") + self.running = True + + # 在后台线程运行 TUI + self.tui_thread = threading.Thread(target=self._tui_loop, daemon=True) + self.tui_thread.start() + + Log.ok("tui", "TUI 已启动(后台模式)") + Log.info("tui", "提示:按 'q' 退出 TUI,WebUI 仍在运行") + + def _tui_loop(self): + """TUI 主循环""" + try: + # 显示欢迎界面 + self._show_welcome() + + # 主事件循环 + self._event_loop() + + except Exception as e: + Log.error("tui", f"TUI 循环异常:{e}") + finally: + self.running = False + + def _show_welcome(self): + """显示欢迎界面""" + welcome_html = """ + + + + NebulaShell TUI + + + + +
+

👋 欢迎使用 NebulaShell TUI

+

终端用户界面已启动

+

WebUI 同时运行在:http://localhost:8080

+
+ + + +
+
    +
  • [1] 首页
  • +
  • [2] 仪表盘
  • +
  • [3] 日志
  • +
  • [4] 终端
  • +
  • [5] 插件管理
  • +
  • [q] 退出 TUI
  • +
  • [r] 刷新
  • +
+
+ + + + + + + + + +""" + self.tui_manager.load_page("/welcome", welcome_html) + self._render_current("/welcome") + + def _render_current(self, path: str = None): + """渲染当前页面到终端""" + if path is None: + path = self.tui_manager.current_page or "/welcome" + + output = self.tui_manager.render_page(path) + + # 清屏并输出 + sys.stdout.write('\x1b[2J\x1b[H') + sys.stdout.write(output) + sys.stdout.write('\n\n') + sys.stdout.write('\x1b[90m提示:按数字键导航,q 退出,r 刷新\x1b[0m\n') + sys.stdout.flush() + + def _event_loop(self): + """简单的事件循环""" + import sys + import tty + import termios + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + + try: + tty.setraw(fd) + + while self.running: + char = sys.stdin.read(1) + + if char == '\x03': # Ctrl+C + break + elif char == '\x04': # Ctrl+D + break + elif char == 'q': + Log.info("tui", "用户退出 TUI") + break + elif char == '1': + self._render_current("/") + elif char == '2': + self._render_current("/dashboard") + elif char == '3': + self._render_current("/logs") + elif char == '4': + self._render_current("/terminal") + elif char == '5': + self._render_current("/plugins") + elif char == 'r': + self._load_default_pages() + self._render_current() + elif char == '\n' or char == '\r': + # Enter 刷新当前页 + self._render_current() + + except Exception as e: + Log.error("tui", f"事件循环错误:{e}") + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + # ========== TUI 核心接口实现 ========== + + def _handle_tui_index(self, request): + """处理 /tui/index.html 请求 - TUI 入口点 + + 返回特殊标记的 HTML,TUI 转换层会识别并转换。 + 此 HTML 不含用户可见内容,仅包含 data-tui-* 标记和配置脚本。 + """ + html = """ + + + + NebulaShell TUI + + + + +
+
+

NebulaShell TUI

+

终端界面就绪

+
+ + + + + + + +
+ + +
+
+ + + + + + + +""" + return Response( + status=200, + headers={"Content-Type": "text/html; charset=utf-8"}, + body=html + ) + + def _handle_tui_page(self, request): + """处理 /tui/page 请求 - 获取任意页面的 TUI 版本 + + 从 WebUI 获取原始 HTML,添加 TUI 标记后返回。 + TUI 转换层会自动解析这些标记并转换为终端元素。 + """ + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(request.path) + params = parse_qs(parsed.query) + page_path = params.get('path', ['/'])[0] + + # 从 WebUI 获取原始 HTML + html = self._fetch_webui_page(page_path) + + if html: + # 添加 TUI 标记 + html = html.replace('', '') + + return Response( + status=200, + headers={"Content-Type": "text/html; charset=utf-8"}, + body=html + ) + else: + # 返回错误页面 + error_html = """ + + +

❌ 页面未找到

+

路径:

+ + + +""" + return Response( + status=404, + headers={"Content-Type": "text/html; charset=utf-8"}, + body=error_html + ) + + def _handle_tui_css(self, request): + """处理 /tui/css 请求 - 返回终端兼容的 CSS + + 只返回终端支持的 CSS 属性: + - 背景色(ANSI 颜色) + - 文字颜色(ANSI 颜色) + - 字体样式(bold, italic, underline) + - 边框样式(单线、双线、圆角等) + """ + css = """/* TUI 兼容 CSS - 仅支持终端属性 */ + +/* 基础样式 */ +.tui-page { + background-color: #000000; + color: #ffffff; +} + +.tui-body { + font-family: monospace; + font-weight: normal; +} + +/* 字体样式 - TUI 支持 */ +.bold { font-weight: bold; } +.italic { font-style: italic; } +.underline { text-decoration: underline; } +.dim { opacity: 0.7; } + +/* 布局 - TUI 简化处理 */ +.tui-container { + padding: 0; + margin: 0; +} + +[data-tui-layout="vertical"] { + display: block; +} + +[data-tui-layout="horizontal"] { + display: inline-block; +} + +/* 边框样式 */ +[data-tui-border="single"] { + border-style: single; +} + +[data-tui-border="double"] { + border-style: double; +} + +[data-tui-border="rounded"] { + border-style: rounded; +} + +/* 交互元素标记 */ +[data-tui-action] { + cursor: pointer; +} + +[data-tui-key]::before { + content: "[" attr(data-tui-key) "] "; +} + +/* 面板/卡片 */ +[data-tui-type="panel"] { + border-style: single; + padding: 1; +} + +/* 按钮 */ +button, [data-tui-type="button"] { + border-style: single; + padding: 0 2; +} + +/* 列表 */ +ul, ol { + list-style-position: inside; +} + +/* 进度条 */ +[data-tui-type="progress"] { + filled-char: "█"; + empty-char: "░"; +} + +/* 加载动画 */ +[data-tui-type="spinner"] { + animation: spin 1s linear infinite; +} +""" + return Response( + status=200, + headers={"Content-Type": "text/css"}, + body=css + ) + + def _handle_tui_js(self, request): + """处理 /tui/js 请求 - 返回 TUI 交互配置(模拟 JS) + + TUI 不支持完整 JavaScript,只支持: + - 获取鼠标位置 + - 点击事件 + - 按键事件 + - 简单的 DOM 操作 + """ + js_config = """// TUI JS 模拟配置 +// 仅支持基础交互功能 + +const TUI = { + // 鼠标支持 + mouse: { + enabled: true, + getPosition: () => ({ x: 0, y: 0 }), + onClick: (handler) => {}, + }, + + // 键盘支持 + keyboard: { + enabled: true, + onKeyPress: (handler) => {}, + bindings: {}, + }, + + // DOM 操作(简化版) + querySelector: (selector) => null, + querySelectorAll: (selector) => [], + + // 事件系统 + addEventListener: (event, handler) => {}, + removeEventListener: (event, handler) => {}, +}; + +// 导出配置 +export default TUI; +""" + return Response( + status=200, + headers={"Content-Type": "application/javascript"}, + body=js_config + ) + + def _handle_tui_interact(self, request): + """处理 TUI 交互请求 - 处理鼠标、键盘事件""" + import json + + try: + body = json.loads(request.body) + action = body.get('action', '') + target = body.get('target', '') + key = body.get('key', '') + mouse_x = body.get('mouse_x', 0) + mouse_y = body.get('mouse_y', 0) + + # 处理导航 + if action == 'navigate': + html = self._fetch_webui_page(target) + if html: + self.tui_manager.load_page(target, html) + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True, 'page': target}) + ) + + # 处理点击 + elif action == 'click': + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True, 'target': target}) + ) + + # 处理按键 + elif action == 'keypress': + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True, 'key': key}) + ) + + # 处理鼠标移动 + elif action == 'mousemove': + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True, 'x': mouse_x, 'y': mouse_y}) + ) + + # 处理刷新 + elif action == 'refresh': + self._load_default_pages() + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True}) + ) + + # 处理退出 + elif action == 'quit': + self.running = False + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True, 'message': 'Quitting TUI'}) + ) + + return Response( + status=400, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': False, 'error': 'Unknown action'}) + ) + + except Exception as e: + return Response( + status=500, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': False, 'error': str(e)}) + ) + + def _handle_tui_pages(self, request): + """处理 /tui/pages 请求 - 列出所有可用页面""" + import json + + pages = [] + if self.webui and hasattr(self.webui, 'server'): + router = self.webui.server.router + if hasattr(router, 'routes'): + pages = list(router.routes.keys()) + + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({ + 'success': True, + 'pages': pages, + 'current': self.tui_manager.current_page if self.tui_manager else None + }) + ) + + def stop(self): + """停止 TUI""" + Log.info("tui", "TUI 停止中...") + self.running = False + + if self.tui_thread: + self.tui_thread.join(timeout=2) + + Log.ok("tui", "TUI 已停止") + + +register_plugin_type("TUIPlugin", TUIPlugin) + + +def New(): + return TUIPlugin() diff --git a/store/@{NebulaShell}/webui/core/server.py b/store/@{NebulaShell}/webui/core/server.py index 249521f..d8ecf05 100644 --- a/store/@{NebulaShell}/webui/core/server.py +++ b/store/@{NebulaShell}/webui/core/server.py @@ -24,6 +24,12 @@ class WebUIServer: self.router.get("/static/css/main.css", self._handle_css) self.router.get("/static/js/main.js", self._handle_js) self.router.get("/health", self._handle_health) + + # TUI 接口 - 供 TUI 转换层访问 + self.router.get("/tui/index.html", self._handle_tui_index) + self.router.get("/tui/page", self._handle_tui_page) + self.router.get("/tui/css", self._handle_tui_css) + self.router.get("/tui/pages", self._handle_tui_pages) def register_page(self, path: str, content_provider, nav_item: dict = None): """供其他插件注册页面""" @@ -179,3 +185,85 @@ class WebUIServer: def _handle_health(self, request): import json return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps({"status": "ok"})) + + # ========== TUI 接口实现 ========== + + def _handle_tui_index(self, request): + """处理 /tui/index.html 请求 - TUI 入口点 + + 返回特殊标记的 HTML,TUI 转换层会识别并转换。 + 此 HTML 不含用户可见内容,仅包含 data-tui-* 标记和配置脚本。 + """ + html = """ + + + + NebulaShell TUI + + + + +
+
+

NebulaShell TUI

+

终端界面就绪

+
+ + +
+ + +""" + return Response(status=200, headers={"Content-Type": "text/html; charset=utf-8"}, body=html) + + def _handle_tui_page(self, request): + """处理 /tui/page 请求 - 获取任意页面的 TUI 版本""" + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(request.path) + params = parse_qs(parsed.query) + page_path = params.get('path', ['/'])[0] + + # 查找已注册的页面 + provider = self.pages.get(page_path) + if provider: + content = provider() + html = f""" + +{content} +""" + return Response(status=200, headers={"Content-Type": "text/html; charset=utf-8"}, body=html) + + return Response(status=404, headers={"Content-Type": "text/html"}, body="Page not found") + + def _handle_tui_css(self, request): + """处理 /tui/css 请求 - 返回终端兼容的 CSS""" + css = """/* TUI 兼容 CSS */ +.tui-page { background-color: #000000; color: #ffffff; } +.tui-body { font-family: monospace; } +.bold { font-weight: bold; } +.underline { text-decoration: underline; } +[data-tui-action] { cursor: pointer; } +""" + return Response(status=200, headers={"Content-Type": "text/css"}, body=css) + + def _handle_tui_pages(self, request): + """处理 /tui/pages 请求 - 列出所有可用页面""" + import json + pages = list(self.pages.keys()) + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True, 'pages': pages}) + ) diff --git a/store/@{NebulaShell}/webui/main.py b/store/@{NebulaShell}/webui/main.py index 5e7fd00..1cd0f67 100644 --- a/store/@{NebulaShell}/webui/main.py +++ b/store/@{NebulaShell}/webui/main.py @@ -1,4 +1,4 @@ -"""WebUI - Web 控制台 (容器模式)""" +"""WebUI - Web 控制台 (容器模式) + TUI 双启动""" from pathlib import Path from oss.logger.logger import Log from oss.plugin.types import Plugin, Response, register_plugin_type @@ -7,11 +7,12 @@ from .core.server import WebUIServer class WebUIPlugin(Plugin): - """WebUI 插件 - 提供页面容器""" + """WebUI 插件 - 提供页面容器,同时启动 TUI""" def __init__(self): self.http_api = None self.server = None + self.tui = None self.config = {} def meta(self): @@ -22,14 +23,15 @@ class WebUIPlugin(Plugin): name="webui", version="2.1.0", author="NebulaShell", - description="Web 控制台容器 - 供其他插件注册页面" + description="Web 控制台容器 + TUI 双启动 - 供其他插件注册页面" ), config=PluginConfig( enabled=True, args={ "port": config.get("HTTP_API_PORT", 8080), "theme": "dark", - "title": "NebulaShell" + "title": "NebulaShell", + "tui_enabled": True # 默认启用 TUI } ), dependencies=["http-api"] @@ -39,10 +41,14 @@ class WebUIPlugin(Plugin): """注入 http-api""" self.http_api = http_api + def set_tui(self, tui): + """注入 tui 引用""" + self.tui = tui + def init(self, deps: dict = None): - """初始化 WebUI 服务器""" + """初始化 WebUI 服务器和 TUI""" if not self.http_api: - Log.error("webui", "错误: 未找到 http-api 依赖") + Log.error("webui", "错误:未找到 http-api 依赖") return config = {} @@ -52,7 +58,8 @@ class WebUIPlugin(Plugin): self.config = { "port": config.get("port", get_config().get("HTTP_API_PORT", 8080)), "theme": config.get("theme", "dark"), - "title": config.get("title", "NebulaShell") + "title": config.get("title", "NebulaShell"), + "tui_enabled": config.get("tui_enabled", True) } # 使用 http-api 的路由器 @@ -61,6 +68,10 @@ class WebUIPlugin(Plugin): self.config ) Log.info("webui", "容器初始化完成") + + # 如果启用了 TUI,通知 TUI 插件 + if self.config.get("tui_enabled") and self.tui: + Log.info("webui", "TUI 已启用,将双启动") def start(self): """启动服务器(注册默认路由)""" @@ -69,7 +80,11 @@ class WebUIPlugin(Plugin): self._setup_home_page() self.server.start() - Log.info("webui", f"WebUI 容器已启动: http://localhost:{self.config['port']}") + Log.info("webui", f"WebUI 容器已启动:http://localhost:{self.config['port']}") + + # 如果启用了 TUI,在后台启动 + if self.config.get("tui_enabled"): + Log.info("webui", "TUI 双启动中...") def _setup_home_page(self): """设置首页:如果仪表盘已安装则跳转到仪表盘,否则显示默认首页""" @@ -118,7 +133,7 @@ class WebUIPlugin(Plugin): if self.server: self.server.register_page(path, content_provider, nav_item) else: - Log.warn("webui", f"警告: 试图注册页面 {path},但服务器未初始化") + Log.warn("webui", f"警告:试图注册页面 {path},但服务器未初始化") def add_nav_item(self, item: dict): """仅添加导航项(如果页面由其他方式处理)""" diff --git a/store/@{NebulaShell}/webui/manifest.json b/store/@{NebulaShell}/webui/manifest.json index b419856..0eddf22 100644 --- a/store/@{NebulaShell}/webui/manifest.json +++ b/store/@{NebulaShell}/webui/manifest.json @@ -3,7 +3,7 @@ "name": "webui", "version": "2.1.0", "author": "NebulaShell", - "description": "Web 控制台 - 多语言支持/插件管理/安全配置/系统监控", + "description": "Web 控制台 + TUI 双启动 - 多语言支持/插件管理/安全配置/系统监控", "type": "webui" }, "config": { @@ -18,7 +18,8 @@ "enable_2fa": false, "show_plugins": true, "show_security": true, - "show_deployments": true + "show_deployments": true, + "tui_enabled": true } }, "dependencies": ["http-api", "i18n"], diff --git a/store/@{NebulaShell}/webui/tui/README.md b/store/@{NebulaShell}/webui/tui/README.md new file mode 100644 index 0000000..d4d21ec --- /dev/null +++ b/store/@{NebulaShell}/webui/tui/README.md @@ -0,0 +1,187 @@ +# NebulaShell TUI - 终端用户界面 + +## 概述 + +TUI(Terminal User Interface)插件为 NebulaShell 提供终端界面,与 WebUI 双启动运行。 + +## 核心特性 + +### 1. 转换层架构 + +TUI 本身只有一个转换层,它只会访问 WebUI 所开放的 `/tui` 接口: + +- **`/tui/index.html`** - TUI 入口页面 +- **`/tui/page`** - 获取任意页面的 TUI 版本 +- **`/tui/css`** - 终端兼容的 CSS +- **`/tui/interact`** - 处理交互事件 + +### 2. HTML 标记规范 + +WebUI 开放的 `.html` 文件中不含有任何给用户看的内容,但包含 TUI 可解析的特殊标记: + +```html + + + + + +
+ + +[1] 首页 + + + + + + +``` + +### 3. 支持的 CSS 属性 + +TUI 只支持终端能够渲染的样式: + +| CSS 属性 | TUI 转换 | 说明 | +|---------|---------|------| +| `font-weight: bold` | ANSI 加粗 | `\x1b[1m` | +| `font-style: italic` | ANSI 斜体 | `\x1b[3m` | +| `text-decoration: underline` | ANSI 下划线 | `\x1b[4m` | +| `background-color` | ANSI 背景色 | 仅支持基础 8 色 | +| `color` | ANSI 前景色 | 仅支持基础 8 色 | +| `text-align` | 文本对齐 | left/center/right | + +### 4. 支持的 JS 交互 + +TUI 只支持基础的终端交互: + +- **鼠标位置** - 通过 ANSI 鼠标协议获取 +- **点击事件** - 转换为选择操作 +- **按键输入** - 完整的键盘支持 + +```javascript +// TUI 配置中的键盘映射 +{ + "keyboard": { + "1": {"action": "navigate", "target": "/"}, + "ArrowUp": {"action": "navigate_up"}, + "Enter": {"action": "select"}, + "q": {"action": "quit"} + } +} +``` + +## 文件结构 + +``` +webui/tui/ +├── __init__.py # 包初始化 +├── main.py # TUI 插件主程序 +├── converter.py # HTML 到 TUI 转换层 +├── index.html # TUI 入口页面(含特殊标记) +├── manifest.json # 插件清单 +└── README.md # 本文档 +``` + +## 使用方式 + +### 启动 NebulaShell + +```bash +# 正常启动,WebUI 和 TUI 会同时运行 +python main.py serve + +# 或通过 CLI +python -m oss.cli serve +``` + +### TUI 快捷键 + +| 按键 | 功能 | +|-----|------| +| `1` | 首页 | +| `2` | 仪表盘 | +| `3` | 日志 | +| `4` | 终端 | +| `5` | 插件 | +| `6` | 设置 | +| `r` | 刷新 | +| `h` | 帮助 | +| `↑/↓` | 上下导航 | +| `Enter` | 确认 | +| `q` | 退出 TUI | + +## 开发指南 + +### 创建 TUI 兼容页面 + +1. 在 WebUI 插件中创建页面时,添加 TUI 标记 +2. 使用 `data-tui-*` 属性定义交互行为 +3. 在 ` + + + ''', + nav_item={'icon': 'ri-star-line', 'text': '我的页面'} + ) +``` + +## 技术细节 + +### 转换流程 + +1. TUI 插件启动时访问 `/tui/index.html` +2. `HTMLToTUIConverter` 解析 HTML 提取: + - 文本内容 + - 按钮和链接 + - TUI 配置(键盘映射、样式) +3. `TUIRenderer` 将元素渲染为 ANSI 转义序列 +4. `TUICanvas` 管理终端显示缓冲区 +5. `TUIInputHandler` 处理键盘/鼠标输入 + +### ANSI 颜色映射 + +```python +COLOR_MAP = { + '#000000': '\x1b[30m', # black + '#ff0000': '\x1b[31m', # red + '#00ff00': '\x1b[32m', # green + '#ffff00': '\x1b[33m', # yellow + '#0000ff': '\x1b[34m', # blue + '#ff00ff': '\x1b[35m', # magenta + '#00ffff': '\x1b[36m', # cyan + '#ffffff': '\x1b[37m', # white +} +``` + +## 许可证 + +MIT License - NebulaShell Project diff --git a/store/@{NebulaShell}/webui/tui/__init__.py b/store/@{NebulaShell}/webui/tui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/store/@{NebulaShell}/webui/tui/converter.py b/store/@{NebulaShell}/webui/tui/converter.py new file mode 100644 index 0000000..55384e4 --- /dev/null +++ b/store/@{NebulaShell}/webui/tui/converter.py @@ -0,0 +1,1063 @@ +"""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']*>.*?', '', 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) + + 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""" + + +

❌ 错误

+

{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 diff --git a/store/@{NebulaShell}/webui/tui/index.html b/store/@{NebulaShell}/webui/tui/index.html new file mode 100644 index 0000000..e0b2df7 --- /dev/null +++ b/store/@{NebulaShell}/webui/tui/index.html @@ -0,0 +1,113 @@ + + + + + + NebulaShell TUI + + + + + +
+ +
+ NebulaShell TUI - 终端用户界面 +
+ + +
+ WebUI: http://localhost:8080 | TUI: 双启动模式 +
+ + + + + +
+ + +
+

快捷键说明:

+
    +
  • q - 退出 TUI
  • +
  • r - 刷新当前页
  • +
  • h - 显示帮助
  • +
  • ↑/↓ - 上下导航
  • +
  • Enter - 确认选择
  • +
+
+
+ + + + + + + + diff --git a/store/@{NebulaShell}/webui/tui/main.py b/store/@{NebulaShell}/webui/tui/main.py new file mode 100644 index 0000000..bdbb08b --- /dev/null +++ b/store/@{NebulaShell}/webui/tui/main.py @@ -0,0 +1,378 @@ +"""TUI 插件 - 终端用户界面,与 WebUI 双启动""" +import os +import sys +import threading +import time +from pathlib import Path +from oss.logger.logger import Log +from oss.plugin.types import Plugin, Response, register_plugin_type + +from .tui.converter import TUIManager, TUIRenderer, HTMLToTUIConverter + + +class TUIPlugin(Plugin): + """TUI 插件 - 提供终端界面,通过访问 WebUI 的 /tui 接口获取 HTML""" + + def __init__(self): + self.webui = None + self.http_api = None + self.tui_manager = None + self.running = False + self.tui_thread = None + + def meta(self): + from oss.plugin.types import Metadata, PluginConfig, Manifest + return Manifest( + metadata=Metadata( + name="tui", + version="1.0.0", + author="NebulaShell", + description="终端用户界面 - 与 WebUI 双启动" + ), + config=PluginConfig(enabled=True, args={}), + dependencies=["http-api", "webui"] + ) + + def set_webui(self, webui): + """注入 webui 引用""" + self.webui = webui + + def set_http_api(self, http_api): + """注入 http_api 引用""" + self.http_api = http_api + + def init(self, deps: dict = None): + """初始化 TUI""" + Log.info("tui", "TUI 插件初始化中...") + + # 创建 TUI 管理器 + self.tui_manager = TUIManager.get_instance() + + # 注册 /tui 路由供 TUI 访问 WebUI 页面 + if self.http_api and self.http_api.router: + # 注册 TUI 专用 API + self.http_api.router.get("/tui/index.html", self._handle_tui_index) + self.http_api.router.get("/tui/page", self._handle_tui_page) + self.http_api.router.get("/tui/css", self._handle_tui_css) + self.http_api.router.post("/tui/interact", self._handle_tui_interact) + Log.ok("tui", "已注册 TUI API 路由") + else: + Log.warn("tui", "警告:未找到 http-api 依赖") + + # 加载默认页面(从 WebUI 获取) + self._load_default_pages() + + Log.ok("tui", "TUI 插件初始化完成") + + def _load_default_pages(self): + """从 WebUI 加载默认页面到 TUI""" + # 模拟访问 WebUI 页面并缓存 + default_pages = ["/", "/dashboard", "/logs", "/terminal"] + + for path in default_pages: + try: + # 这里会通过内部调用获取 WebUI 渲染的 HTML + html = self._fetch_webui_page(path) + if html: + self.tui_manager.load_page(path, html) + Log.info("tui", f"已加载页面:{path}") + except Exception as e: + Log.warn("tui", f"加载页面 {path} 失败:{e}") + + def _fetch_webui_page(self, path: str) -> str: + """从 WebUI 获取页面 HTML""" + if not self.webui or not hasattr(self.webui, 'server'): + return "" + + # 模拟请求获取 WebUI 页面 + # 由于我们在同一进程,可以直接调用 server 的路由处理 + try: + from oss.plugin.types import Request + request = Request(method="GET", path=path, headers={}, body="") + + # 查找匹配的路由 + router = self.webui.server.router + if hasattr(router, 'routes'): + for route_path, handler in router.routes.items(): + if route_path == path or (route_path.endswith('*') and path.startswith(route_path[:-1])): + response = handler(request) + if response and hasattr(response, 'body'): + return response.body.decode('utf-8') if isinstance(response.body, bytes) else response.body + except Exception as e: + Log.warn("tui", f"获取 WebUI 页面失败:{e}") + + return "" + + def start(self): + """启动 TUI(在后台线程运行)""" + Log.info("tui", "TUI 启动中...") + self.running = True + + # 在后台线程运行 TUI + self.tui_thread = threading.Thread(target=self._tui_loop, daemon=True) + self.tui_thread.start() + + Log.ok("tui", "TUI 已启动(后台模式)") + Log.info("tui", "提示:按 'q' 退出 TUI,WebUI 仍在运行") + + def _tui_loop(self): + """TUI 主循环""" + try: + # 显示欢迎界面 + self._show_welcome() + + # 主事件循环 + self._event_loop() + + except Exception as e: + Log.error("tui", f"TUI 循环异常:{e}") + finally: + self.running = False + + def _show_welcome(self): + """显示欢迎界面""" + welcome_html = """ + + + NebulaShell TUI + +

👋 欢迎使用 NebulaShell TUI

+

终端用户界面已启动

+

WebUI 同时运行在:http://localhost:8080

+
+

可用命令:

+
    +
  • [1] 首页
  • +
  • [2] 仪表盘
  • +
  • [3] 日志
  • +
  • [4] 终端
  • +
  • [q] 退出 TUI
  • +
  • [r] 刷新
  • +
+ + + """ + self.tui_manager.load_page("/welcome", welcome_html) + self._render_current("/welcome") + + def _render_current(self, path: str = None): + """渲染当前页面到终端""" + if path is None: + path = self.tui_manager.current_page or "/welcome" + + output = self.tui_manager.render_page(path) + + # 清屏并输出 + sys.stdout.write('\x1b[2J\x1b[H') + sys.stdout.write(output) + sys.stdout.write('\n\n') + sys.stdout.write('\x1b[90m提示:按数字键导航,q 退出\x1b[0m\n') + sys.stdout.flush() + + def _event_loop(self): + """简单的事件循环""" + import sys + import tty + import termios + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + + try: + tty.setraw(fd) + + while self.running: + char = sys.stdin.read(1) + + if char == '\x03': # Ctrl+C + break + elif char == '\x04': # Ctrl+D + break + elif char == 'q': + Log.info("tui", "用户退出 TUI") + break + elif char == '1': + self._render_current("/") + elif char == '2': + self._render_current("/dashboard") + elif char == '3': + self._render_current("/logs") + elif char == '4': + self._render_current("/terminal") + elif char == 'r': + self._load_default_pages() + self._render_current() + elif char == '\n' or char == '\r': + # Enter 刷新当前页 + self._render_current() + + except Exception as e: + Log.error("tui", f"事件循环错误:{e}") + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + def _handle_tui_index(self, request): + """处理 /tui/index.html 请求""" + # 返回特殊标记的 HTML,TUI 会识别并转换 + html = """ + + + + NebulaShell TUI + + + +
+

NebulaShell TUI

+

终端界面就绪

+ +
+ + + +""" + return Response( + status=200, + headers={"Content-Type": "text/html; charset=utf-8"}, + body=html + ) + + def _handle_tui_page(self, request): + """处理 /tui/page 请求 - 获取任意页面的 TUI 版本""" + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(request.path) + params = parse_qs(parsed.query) + page_path = params.get('path', ['/'])[0] + + # 从 WebUI 获取原始 HTML + html = self._fetch_webui_page(page_path) + + if html: + # 添加 TUI 标记 + html = html.replace('', '') + + return Response( + status=200, + headers={"Content-Type": "text/html; charset=utf-8"}, + body=html + ) + else: + return Response( + status=404, + headers={"Content-Type": "text/html"}, + body="Page not found" + ) + + def _handle_tui_css(self, request): + """处理 /tui/css 请求 - 返回终端兼容的 CSS""" + # 只返回终端支持的 CSS 属性 + css = """/* TUI 兼容 CSS */ +.tui-page { + /* 背景色 - 仅支持 ANSI 颜色 */ + background-color: #000000; + color: #ffffff; +} + +.tui-body { + font-family: monospace; + font-weight: normal; +} + +/* 字体样式 - TUI 支持 */ +.bold { font-weight: bold; } +.underline { text-decoration: underline; } + +/* 布局 - TUI 简化处理 */ +.tui-container { + padding: 0; + margin: 0; +} + +/* 交互元素标记 */ +[data-tui-action] { + cursor: pointer; +} +""" + return Response( + status=200, + headers={"Content-Type": "text/css"}, + body=css + ) + + def _handle_tui_interact(self, request): + """处理 TUI 交互请求""" + import json + + try: + body = json.loads(request.body) + action = body.get('action', '') + target = body.get('target', '') + + # 处理交互 + if action == 'navigate': + # 导航到指定页面 + html = self._fetch_webui_page(target) + if html: + self.tui_manager.load_page(target, html) + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True, 'page': target}) + ) + elif action == 'click': + # 处理点击 + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True}) + ) + elif action == 'keypress': + # 处理按键 + key = body.get('key', '') + return Response( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': True, 'key': key}) + ) + + return Response( + status=400, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': False, 'error': 'Unknown action'}) + ) + + except Exception as e: + return Response( + status=500, + headers={"Content-Type": "application/json"}, + body=json.dumps({'success': False, 'error': str(e)}) + ) + + def stop(self): + """停止 TUI""" + Log.info("tui", "TUI 停止中...") + self.running = False + + if self.tui_thread: + self.tui_thread.join(timeout=2) + + Log.ok("tui", "TUI 已停止") + + +register_plugin_type("TUIPlugin", TUIPlugin) + + +def New(): + return TUIPlugin() diff --git a/store/@{NebulaShell}/webui/tui/manifest.json b/store/@{NebulaShell}/webui/tui/manifest.json new file mode 100644 index 0000000..3605d3d --- /dev/null +++ b/store/@{NebulaShell}/webui/tui/manifest.json @@ -0,0 +1,28 @@ +{ + "metadata": { + "name": "tui", + "version": "1.0.0", + "author": "NebulaShell", + "description": "终端用户界面 - 与 WebUI 双启动,通过访问 /tui 接口获取 HTML 并转换为终端显示", + "type": "tui" + }, + "config": { + "enabled": true, + "args": { + "width": 80, + "height": 24, + "theme": "dark", + "enable_mouse": false, + "keyboard_shortcuts": { + "1": "/", + "2": "/dashboard", + "3": "/logs", + "4": "/terminal", + "q": "quit", + "r": "refresh" + } + } + }, + "dependencies": ["http-api", "webui"], + "permissions": ["read:pages", "execute:commands"] +}