feat: Implement TUT 2.0 with new rendering architecture

Major features:
- New modular architecture with Terminal, FrameBuffer, Renderer layers
- True Color (24-bit) support with warm, eye-friendly color scheme
- Unicode support with proper CJK character width handling
- Differential rendering for improved performance
- Page caching (LRU, 20 pages, 5-minute expiry)
- Search functionality with highlighting (/, n/N)
- Form rendering (input, button, checkbox, radio, select)
- Image placeholder support ([alt text] or [Image: filename])
- Binary data download via fetch_binary()
- Loading state indicators

New files:
- src/browser_v2.cpp/h - Browser with new rendering system
- src/main_v2.cpp - Entry point for tut2
- src/render/* - Terminal, FrameBuffer, Renderer, Layout, Image modules
- src/utils/unicode.cpp/h - Unicode handling utilities
- tests/* - Test programs for each module

Build with: cmake --build build_v2
Run: ./build_v2/tut2 [URL]
This commit is contained in:
m1ngsama 2025-12-26 14:55:18 +08:00
parent dec72c678f
commit d80d0a1c6e
25 changed files with 4418 additions and 38 deletions

View file

@ -1,58 +1,130 @@
cmake_minimum_required(VERSION 3.15)
project(TUT VERSION 1.0 LANGUAGES CXX)
project(TUT_v2 VERSION 2.0.0 LANGUAGES CXX)
# C++17标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Prefer wide character support (ncursesw)
set(CURSES_NEED_WIDE TRUE)
# 编译选项
add_compile_options(-Wall -Wextra -Wpedantic)
# macOS: Use Homebrew ncurses if available
if(APPLE)
set(CMAKE_PREFIX_PATH "/opt/homebrew/opt/ncurses" ${CMAKE_PREFIX_PATH})
endif()
find_package(Curses REQUIRED)
# 查找依赖库
find_package(CURL REQUIRED)
# Find gumbo-parser for HTML parsing
find_package(Curses REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(GUMBO REQUIRED gumbo)
# Executable
add_executable(tut
src/main.cpp
src/http_client.cpp
src/dom_tree.cpp
src/html_parser.cpp
src/text_renderer.cpp
src/input_handler.cpp
src/browser.cpp
# 包含目录
include_directories(
${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/src/core
${CMAKE_SOURCE_DIR}/src/render
${CMAKE_SOURCE_DIR}/src/layout
${CMAKE_SOURCE_DIR}/src/parser
${CMAKE_SOURCE_DIR}/src/network
${CMAKE_SOURCE_DIR}/src/input
${CMAKE_SOURCE_DIR}/src/utils
${CURL_INCLUDE_DIRS}
${CURSES_INCLUDE_DIRS}
${GUMBO_INCLUDE_DIRS}
)
target_include_directories(tut PRIVATE
${CURSES_INCLUDE_DIR}
${GUMBO_INCLUDE_DIRS}
# ==================== Terminal 测试程序 ====================
add_executable(test_terminal
src/render/terminal.cpp
tests/test_terminal.cpp
)
target_link_libraries(test_terminal
${CURSES_LIBRARIES}
)
# ==================== Renderer 测试程序 ====================
add_executable(test_renderer
src/render/terminal.cpp
src/render/renderer.cpp
src/utils/unicode.cpp
tests/test_renderer.cpp
)
target_link_libraries(test_renderer
${CURSES_LIBRARIES}
)
# ==================== Layout 测试程序 ====================
add_executable(test_layout
src/render/terminal.cpp
src/render/renderer.cpp
src/render/layout.cpp
src/render/image.cpp
src/utils/unicode.cpp
src/dom_tree.cpp
src/html_parser.cpp
tests/test_layout.cpp
)
target_link_directories(test_layout PRIVATE
${GUMBO_LIBRARY_DIRS}
)
target_link_libraries(test_layout
${CURSES_LIBRARIES}
${GUMBO_LIBRARIES}
)
# ==================== TUT 2.0 主程序 ====================
add_executable(tut2
src/main_v2.cpp
src/browser_v2.cpp
src/http_client.cpp
src/input_handler.cpp
src/render/terminal.cpp
src/render/renderer.cpp
src/render/layout.cpp
src/render/image.cpp
src/utils/unicode.cpp
src/dom_tree.cpp
src/html_parser.cpp
)
target_link_directories(tut2 PRIVATE
${GUMBO_LIBRARY_DIRS}
)
target_link_libraries(tut2
${CURSES_LIBRARIES}
CURL::libcurl
${GUMBO_LIBRARIES}
)
# ==================== 旧版主程序 (向后兼容) ====================
add_executable(tut
src/main.cpp
src/browser.cpp
src/http_client.cpp
src/text_renderer.cpp
src/input_handler.cpp
src/dom_tree.cpp
src/html_parser.cpp
)
target_link_directories(tut PRIVATE
${GUMBO_LIBRARY_DIRS}
)
target_link_libraries(tut PRIVATE
target_link_libraries(tut
${CURSES_LIBRARIES}
CURL::libcurl
${GUMBO_LIBRARIES}
)
# Compiler warnings
target_compile_options(tut PRIVATE
-Wall -Wextra -Wpedantic
$<$<CONFIG:RELEASE>:-O2>
$<$<CONFIG:DEBUG>:-g -O0>
)
# Installation
install(TARGETS tut DESTINATION bin)

140
NEXT_STEPS.md Normal file
View file

@ -0,0 +1,140 @@
# TUT 2.0 - 下次继续从这里开始
## 当前位置
- **阶段**: Phase 4 - 图片支持
- **进度**: 基础功能完成 (占位符显示)
- **最后完成**: 图片占位符渲染 + 二进制数据下载支持
## Phase 4 已完成功能
### 图片占位符 (已完成)
- `<img>` 标签解析和渲染
- 显示 alt 文本或文件名
- 格式: `[Example Photo]``[Image: filename.jpg]`
### 二进制下载支持 (已完成)
- `HttpClient::fetch_binary()` 方法
- `BinaryResponse` 结构体
### ASCII Art 渲染器框架 (已完成)
- `ImageRenderer`
- 支持 ASCII、块字符、盲文三种模式
- PPM 格式解码(内置,无需外部库)
## 启用完整图片支持
要支持 PNG、JPEG 等常见格式,需要下载 stb_image.h:
```bash
# 下载 stb_image.h 到 src/utils/ 目录
curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \
-o src/utils/stb_image.h
# 重新编译
cmake --build build_v2
```
## Phase 3 已完成功能
### 页面缓存
- LRU缓存最多20个页面
- 5分钟缓存过期
- 刷新时跳过缓存 (`r` 键)
- 缓存页面状态栏显示图标
### 差分渲染优化
- 批量输出连续相同样式的字符
- 减少光标移动次数
- 只更新变化的单元格
### 加载状态指示
- 连接状态、解析状态
- 缓存加载、错误状态
## Phase 2 已完成功能
### 搜索功能
- `/` 触发搜索模式
- `n`/`N` 跳转匹配
- 搜索高亮
### 链接导航完善
- Tab切换时自动滚动到链接位置
### 窗口大小调整
- 动态获取尺寸
- 自动重新布局
### 表单渲染
- 输入框、按钮、复选框等
## 文件变更 (Phase 4)
### 新增文件
- `src/render/image.h` - 图片渲染器头文件
- `src/render/image.cpp` - 图片渲染器实现
### 修改的文件
- `src/dom_tree.h` - 添加 img_src, img_width, img_height 属性
- `src/dom_tree.cpp` - 解析 img 标签的 src, width, height 属性
- `src/render/layout.h` - 添加 layout_image_element 方法
- `src/render/layout.cpp` - 实现图片布局
- `src/http_client.h` - 添加 BinaryResponse, fetch_binary
- `src/http_client.cpp` - 实现 fetch_binary
- `CMakeLists.txt` - 添加 image.cpp
## 下一步要做
### 实现真正的 ASCII Art 图片渲染
1. 下载 stb_image.h
2. 在 browser_v2.cpp 中添加图片下载逻辑
3. 调用 ImageRenderer 渲染图片
4. 将 ASCII art 结果插入布局
### 其他可选功能
- 异步HTTP请求
- 书签管理
- 更多表单交互
## 构建命令
```bash
# 配置
cmake -B build_v2 -S . -DCMAKE_BUILD_TYPE=Debug
# 编译全部
cmake --build build_v2
# 运行TUT 2.0
./build_v2/tut2 # 显示帮助
./build_v2/tut2 https://example.com # 打开网页
# 测试程序
./build_v2/test_terminal
./build_v2/test_renderer
./build_v2/test_layout
```
## 快捷键
| 键 | 功能 |
|---|---|
| j/k | 上下滚动 |
| Ctrl+d/u | 翻页 |
| gg/G | 顶部/底部 |
| Tab | 下一个链接 |
| Enter | 跟随链接 |
| h/l | 后退/前进 |
| / | 搜索 |
| n/N | 下一个/上一个匹配 |
| r | 刷新(跳过缓存)|
| :o URL | 打开URL |
| :q | 退出 |
| ? | 帮助 |
## 恢复对话时说
> "继续TUT 2.0开发"
---
更新时间: 2025-12-26

630
src/browser_v2.cpp Normal file
View file

@ -0,0 +1,630 @@
#include "browser_v2.h"
#include "dom_tree.h"
#include "render/colors.h"
#include "render/decorations.h"
#include "utils/unicode.h"
#include <algorithm>
#include <sstream>
#include <map>
#include <cctype>
#include <cstdio>
#include <chrono>
#include <ncurses.h>
using namespace tut;
// 缓存条目
struct CacheEntry {
DocumentTree tree;
std::string html;
std::chrono::steady_clock::time_point timestamp;
bool is_expired(int max_age_seconds = 300) const {
auto now = std::chrono::steady_clock::now();
auto age = std::chrono::duration_cast<std::chrono::seconds>(now - timestamp).count();
return age > max_age_seconds;
}
};
class BrowserV2::Impl {
public:
// 网络和解析
HttpClient http_client;
HtmlParser html_parser;
InputHandler input_handler;
// 新渲染系统
Terminal terminal;
std::unique_ptr<FrameBuffer> framebuffer;
std::unique_ptr<Renderer> renderer;
std::unique_ptr<LayoutEngine> layout_engine;
// 文档状态
DocumentTree current_tree;
LayoutResult current_layout;
std::string current_url;
std::vector<std::string> history;
int history_pos = -1;
// 视图状态
int scroll_pos = 0;
int active_link = -1;
int active_field = -1;
std::string status_message;
std::string search_term;
int screen_width = 0;
int screen_height = 0;
// Marks support
std::map<char, int> marks;
// 搜索相关
SearchContext search_ctx;
// 页面缓存
std::map<std::string, CacheEntry> page_cache;
static constexpr int CACHE_MAX_AGE = 300; // 5分钟缓存
static constexpr size_t CACHE_MAX_SIZE = 20; // 最多缓存20个页面
bool init_screen() {
if (!terminal.init()) {
return false;
}
terminal.get_size(screen_width, screen_height);
terminal.use_alternate_screen(true);
terminal.hide_cursor();
// 创建渲染组件
framebuffer = std::make_unique<FrameBuffer>(screen_width, screen_height);
renderer = std::make_unique<Renderer>(terminal);
layout_engine = std::make_unique<LayoutEngine>(screen_width);
return true;
}
void cleanup_screen() {
terminal.show_cursor();
terminal.use_alternate_screen(false);
terminal.cleanup();
}
void handle_resize() {
terminal.get_size(screen_width, screen_height);
framebuffer = std::make_unique<FrameBuffer>(screen_width, screen_height);
layout_engine->set_viewport_width(screen_width);
// 重新布局当前文档
if (current_tree.root) {
current_layout = layout_engine->layout(current_tree);
}
renderer->force_redraw();
}
bool load_page(const std::string& url, bool force_refresh = false) {
// 检查缓存
auto cache_it = page_cache.find(url);
bool use_cache = !force_refresh && cache_it != page_cache.end() &&
!cache_it->second.is_expired(CACHE_MAX_AGE);
if (use_cache) {
status_message = "⚡ Loading from cache...";
draw_screen();
// 使用缓存的文档树
// 注意需要重新解析因为DocumentTree包含unique_ptr
current_tree = html_parser.parse_tree(cache_it->second.html, url);
status_message = "" + (current_tree.title.empty() ? url : current_tree.title);
} else {
status_message = "⏳ Connecting to " + extract_host(url) + "...";
draw_screen();
auto response = http_client.fetch(url);
if (!response.is_success()) {
status_message = "" + (response.error_message.empty() ?
"HTTP " + std::to_string(response.status_code) :
response.error_message);
return false;
}
status_message = "📄 Parsing HTML...";
draw_screen();
// 解析HTML
current_tree = html_parser.parse_tree(response.body, url);
// 添加到缓存
add_to_cache(url, response.body);
status_message = current_tree.title.empty() ? url : current_tree.title;
}
// 布局计算
current_layout = layout_engine->layout(current_tree);
current_url = url;
scroll_pos = 0;
active_link = current_tree.links.empty() ? -1 : 0;
active_field = current_tree.form_fields.empty() ? -1 : 0;
search_ctx = SearchContext(); // 清除搜索状态
search_term.clear();
// 更新历史(仅在非刷新时)
if (!force_refresh) {
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
history.erase(history.begin() + history_pos + 1, history.end());
}
history.push_back(url);
history_pos = history.size() - 1;
}
return true;
}
void add_to_cache(const std::string& url, const std::string& html) {
// 限制缓存大小
if (page_cache.size() >= CACHE_MAX_SIZE) {
// 移除最老的缓存条目
auto oldest = page_cache.begin();
for (auto it = page_cache.begin(); it != page_cache.end(); ++it) {
if (it->second.timestamp < oldest->second.timestamp) {
oldest = it;
}
}
page_cache.erase(oldest);
}
CacheEntry entry;
entry.html = html;
entry.timestamp = std::chrono::steady_clock::now();
page_cache[url] = std::move(entry);
}
// 从URL中提取主机名
std::string extract_host(const std::string& url) {
// 简单提取:找到://之后的部分,到第一个/为止
size_t proto_end = url.find("://");
if (proto_end == std::string::npos) {
return url;
}
size_t host_start = proto_end + 3;
size_t host_end = url.find('/', host_start);
if (host_end == std::string::npos) {
return url.substr(host_start);
}
return url.substr(host_start, host_end - host_start);
}
void draw_screen() {
// 清空缓冲区
framebuffer->clear_with_color(colors::BG_PRIMARY);
int content_height = screen_height - 1; // 留出状态栏
// 渲染文档内容
RenderContext render_ctx;
render_ctx.active_link = active_link;
render_ctx.active_field = active_field;
render_ctx.search = search_ctx.enabled ? &search_ctx : nullptr;
DocumentRenderer doc_renderer(*framebuffer);
doc_renderer.render(current_layout, scroll_pos, render_ctx);
// 渲染状态栏
draw_status_bar(content_height);
// 渲染到终端
renderer->render(*framebuffer);
}
void draw_status_bar(int y) {
// 状态栏背景
for (int x = 0; x < screen_width; ++x) {
framebuffer->set_cell(x, y, Cell{" ", colors::STATUSBAR_FG, colors::STATUSBAR_BG, ATTR_NONE});
}
// 左侧: 模式
std::string mode_str;
InputMode mode = input_handler.get_mode();
switch (mode) {
case InputMode::NORMAL: mode_str = "NORMAL"; break;
case InputMode::COMMAND:
case InputMode::SEARCH: mode_str = input_handler.get_buffer(); break;
default: mode_str = ""; break;
}
framebuffer->set_text(1, y, mode_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
// 中间: 状态消息或链接URL
std::string display_msg;
if (mode == InputMode::NORMAL) {
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
display_msg = current_tree.links[active_link].url;
}
if (display_msg.empty()) {
display_msg = status_message;
}
if (!display_msg.empty()) {
// 截断过长的消息
size_t max_len = screen_width - mode_str.length() - 20;
if (display_msg.length() > max_len) {
display_msg = display_msg.substr(0, max_len - 3) + "...";
}
int msg_x = static_cast<int>(mode_str.length()) + 3;
framebuffer->set_text(msg_x, y, display_msg, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
}
}
// 右侧: 位置信息
int total_lines = current_layout.total_lines;
int visible_lines = screen_height - 1;
int percentage = (total_lines > 0 && scroll_pos + visible_lines < total_lines) ?
(scroll_pos * 100) / total_lines : 100;
if (total_lines == 0) percentage = 0;
std::string pos_str = std::to_string(scroll_pos + 1) + "/" +
std::to_string(total_lines) + " " +
std::to_string(percentage) + "%";
int pos_x = screen_width - static_cast<int>(pos_str.length()) - 1;
framebuffer->set_text(pos_x, y, pos_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
}
void handle_action(const InputResult& result) {
int visible_lines = screen_height - 1;
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
int count = result.has_count ? result.count : 1;
switch (result.action) {
case Action::SCROLL_UP:
scroll_pos = std::max(0, scroll_pos - count);
break;
case Action::SCROLL_DOWN:
scroll_pos = std::min(max_scroll, scroll_pos + count);
break;
case Action::SCROLL_PAGE_UP:
scroll_pos = std::max(0, scroll_pos - visible_lines);
break;
case Action::SCROLL_PAGE_DOWN:
scroll_pos = std::min(max_scroll, scroll_pos + visible_lines);
break;
case Action::GOTO_TOP:
scroll_pos = 0;
break;
case Action::GOTO_BOTTOM:
scroll_pos = max_scroll;
break;
case Action::GOTO_LINE:
if (result.number > 0) {
scroll_pos = std::min(result.number - 1, max_scroll);
}
break;
case Action::NEXT_LINK:
if (!current_tree.links.empty()) {
active_link = (active_link + 1) % current_tree.links.size();
scroll_to_link(active_link);
}
break;
case Action::PREV_LINK:
if (!current_tree.links.empty()) {
active_link = (active_link - 1 + current_tree.links.size()) % current_tree.links.size();
scroll_to_link(active_link);
}
break;
case Action::FOLLOW_LINK:
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
load_page(current_tree.links[active_link].url);
}
break;
case Action::GO_BACK:
if (history_pos > 0) {
history_pos--;
load_page(history[history_pos]);
}
break;
case Action::GO_FORWARD:
if (history_pos < static_cast<int>(history.size()) - 1) {
history_pos++;
load_page(history[history_pos]);
}
break;
case Action::OPEN_URL:
if (!result.text.empty()) {
load_page(result.text);
}
break;
case Action::REFRESH:
if (!current_url.empty()) {
load_page(current_url, true); // 强制刷新,跳过缓存
}
break;
case Action::SEARCH_FORWARD: {
int count = perform_search(result.text);
if (count > 0) {
status_message = "Match 1/" + std::to_string(count);
} else if (!result.text.empty()) {
status_message = "Pattern not found: " + result.text;
}
break;
}
case Action::SEARCH_NEXT:
search_next();
break;
case Action::SEARCH_PREV:
search_prev();
break;
case Action::HELP:
show_help();
break;
case Action::QUIT:
break; // 在main loop处理
default:
break;
}
}
// 执行搜索,返回匹配数量
int perform_search(const std::string& term) {
search_ctx.matches.clear();
search_ctx.current_match_idx = -1;
search_ctx.enabled = false;
if (term.empty()) {
return 0;
}
search_term = term;
search_ctx.enabled = true;
// 遍历所有布局块和行,查找匹配
int doc_line = 0;
for (const auto& block : current_layout.blocks) {
// 上边距
doc_line += block.margin_top;
// 内容行
for (const auto& line : block.lines) {
// 构建整行文本用于搜索
std::string line_text;
for (const auto& span : line.spans) {
line_text += span.text;
}
// 搜索匹配(大小写不敏感)
std::string lower_line = line_text;
std::string lower_term = term;
std::transform(lower_line.begin(), lower_line.end(), lower_line.begin(), ::tolower);
std::transform(lower_term.begin(), lower_term.end(), lower_term.begin(), ::tolower);
size_t pos = 0;
while ((pos = lower_line.find(lower_term, pos)) != std::string::npos) {
SearchMatch match;
match.line = doc_line;
match.start_col = line.indent + static_cast<int>(pos);
match.length = static_cast<int>(term.length());
search_ctx.matches.push_back(match);
pos += 1; // 继续搜索下一个匹配
}
doc_line++;
}
// 下边距
doc_line += block.margin_bottom;
}
// 如果有匹配,跳转到第一个
if (!search_ctx.matches.empty()) {
search_ctx.current_match_idx = 0;
scroll_to_match(0);
}
return static_cast<int>(search_ctx.matches.size());
}
// 跳转到指定匹配
void scroll_to_match(int idx) {
if (idx < 0 || idx >= static_cast<int>(search_ctx.matches.size())) {
return;
}
search_ctx.current_match_idx = idx;
int match_line = search_ctx.matches[idx].line;
int visible_lines = screen_height - 1;
// 确保匹配行在可见区域
if (match_line < scroll_pos) {
scroll_pos = match_line;
} else if (match_line >= scroll_pos + visible_lines) {
scroll_pos = match_line - visible_lines / 2;
}
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
scroll_pos = std::max(0, std::min(scroll_pos, max_scroll));
}
// 搜索下一个
void search_next() {
if (search_ctx.matches.empty()) {
if (!search_term.empty()) {
status_message = "Pattern not found: " + search_term;
}
return;
}
search_ctx.current_match_idx = (search_ctx.current_match_idx + 1) % search_ctx.matches.size();
scroll_to_match(search_ctx.current_match_idx);
status_message = "Match " + std::to_string(search_ctx.current_match_idx + 1) +
"/" + std::to_string(search_ctx.matches.size());
}
// 搜索上一个
void search_prev() {
if (search_ctx.matches.empty()) {
if (!search_term.empty()) {
status_message = "Pattern not found: " + search_term;
}
return;
}
search_ctx.current_match_idx = (search_ctx.current_match_idx - 1 + search_ctx.matches.size()) % search_ctx.matches.size();
scroll_to_match(search_ctx.current_match_idx);
status_message = "Match " + std::to_string(search_ctx.current_match_idx + 1) +
"/" + std::to_string(search_ctx.matches.size());
}
// 滚动到链接位置
void scroll_to_link(int link_idx) {
if (link_idx < 0 || link_idx >= static_cast<int>(current_layout.link_positions.size())) {
return;
}
const auto& pos = current_layout.link_positions[link_idx];
if (pos.start_line < 0) {
return; // 链接位置无效
}
int visible_lines = screen_height - 1;
int link_line = pos.start_line;
// 确保链接行在可见区域
if (link_line < scroll_pos) {
// 链接在视口上方,滚动使其出现在顶部附近
scroll_pos = std::max(0, link_line - 2);
} else if (link_line >= scroll_pos + visible_lines) {
// 链接在视口下方,滚动使其出现在中间
scroll_pos = link_line - visible_lines / 2;
}
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
scroll_pos = std::max(0, std::min(scroll_pos, max_scroll));
}
void show_help() {
std::string help_html = R"(
<!DOCTYPE html>
<html>
<head><title>TUT 2.0 Help</title></head>
<body>
<h1>TUT 2.0 - Terminal Browser</h1>
<h2>Navigation</h2>
<ul>
<li>j/k - Scroll down/up</li>
<li>Ctrl+d/Ctrl+u - Page down/up</li>
<li>gg - Go to top</li>
<li>G - Go to bottom</li>
</ul>
<h2>Links</h2>
<ul>
<li>Tab - Next link</li>
<li>Shift+Tab - Previous link</li>
<li>Enter - Follow link</li>
</ul>
<h2>History</h2>
<ul>
<li>h - Go back</li>
<li>l - Go forward</li>
</ul>
<h2>Search</h2>
<ul>
<li>/ - Search forward</li>
<li>n - Next match</li>
<li>N - Previous match</li>
</ul>
<h2>Commands</h2>
<ul>
<li>:o URL - Open URL</li>
<li>:q - Quit</li>
<li>? - Show this help</li>
</ul>
<h2>Forms</h2>
<ul>
<li>Tab - Navigate links and form fields</li>
<li>Enter - Activate link or submit form</li>
</ul>
<hr>
<p>TUT 2.0 - A modern terminal browser with True Color support</p>
</body>
</html>
)";
current_tree = html_parser.parse_tree(help_html, "help://");
current_layout = layout_engine->layout(current_tree);
scroll_pos = 0;
active_link = current_tree.links.empty() ? -1 : 0;
status_message = "Help - Press any key to continue";
}
};
BrowserV2::BrowserV2() : pImpl(std::make_unique<Impl>()) {
pImpl->input_handler.set_status_callback([this](const std::string& msg) {
pImpl->status_message = msg;
});
}
BrowserV2::~BrowserV2() = default;
void BrowserV2::run(const std::string& initial_url) {
if (!pImpl->init_screen()) {
throw std::runtime_error("Failed to initialize terminal");
}
if (!initial_url.empty()) {
load_url(initial_url);
} else {
pImpl->show_help();
}
bool running = true;
while (running) {
pImpl->draw_screen();
int ch = pImpl->terminal.get_key(50);
if (ch == -1) continue;
// 处理窗口大小变化
if (ch == KEY_RESIZE) {
pImpl->handle_resize();
continue;
}
auto result = pImpl->input_handler.handle_key(ch);
if (result.action == Action::QUIT) {
running = false;
} else if (result.action != Action::NONE) {
pImpl->handle_action(result);
}
}
pImpl->cleanup_screen();
}
bool BrowserV2::load_url(const std::string& url) {
return pImpl->load_page(url);
}
std::string BrowserV2::get_current_url() const {
return pImpl->current_url;
}

31
src/browser_v2.h Normal file
View file

@ -0,0 +1,31 @@
#pragma once
#include "http_client.h"
#include "html_parser.h"
#include "input_handler.h"
#include "render/terminal.h"
#include "render/renderer.h"
#include "render/layout.h"
#include <string>
#include <vector>
#include <memory>
/**
* BrowserV2 - 使
*
* 使 Terminal + FrameBuffer + Renderer + LayoutEngine
* True Color, Unicode,
*/
class BrowserV2 {
public:
BrowserV2();
~BrowserV2();
void run(const std::string& initial_url = "");
bool load_url(const std::string& url);
std::string get_current_url() const;
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};

View file

@ -265,8 +265,23 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
// Handle IMG
if (element.tag == GUMBO_TAG_IMG) {
GumboAttribute* src_attr = gumbo_get_attribute(&element.attributes, "src");
if (src_attr && src_attr->value) {
node->img_src = resolve_url(src_attr->value, base_url);
}
GumboAttribute* alt_attr = gumbo_get_attribute(&element.attributes, "alt");
if (alt_attr) node->alt_text = alt_attr->value;
GumboAttribute* width_attr = gumbo_get_attribute(&element.attributes, "width");
if (width_attr && width_attr->value) {
try { node->img_width = std::stoi(width_attr->value); } catch (...) {}
}
GumboAttribute* height_attr = gumbo_get_attribute(&element.attributes, "height");
if (height_attr && height_attr->value) {
try { node->img_height = std::stoi(height_attr->value); } catch (...) {}
}
}

View file

@ -34,7 +34,12 @@ struct DomNode {
std::string href;
int link_index = -1; // -1表示非链接
int field_index = -1; // -1表示非表单字段
std::string alt_text; // For images
// 图片属性
std::string img_src; // 图片URL
std::string alt_text; // 图片alt文本
int img_width = -1; // 图片宽度 (-1表示未指定)
int img_height = -1; // 图片高度 (-1表示未指定)
// 表格属性
bool is_table_header = false;

View file

@ -2,13 +2,21 @@
#include <curl/curl.h>
#include <stdexcept>
// 回调函数用于接收数据
// 回调函数用于接收文本数据
static size_t write_callback(void* contents, size_t size, size_t nmemb, std::string* userp) {
size_t total_size = size * nmemb;
userp->append(static_cast<char*>(contents), total_size);
return total_size;
}
// 回调函数用于接收二进制数据
static size_t binary_write_callback(void* contents, size_t size, size_t nmemb, std::vector<uint8_t>* userp) {
size_t total_size = size * nmemb;
uint8_t* data = static_cast<uint8_t*>(contents);
userp->insert(userp->end(), data, data + total_size);
return total_size;
}
class HttpClient::Impl {
public:
CURL* curl;
@ -117,6 +125,75 @@ HttpResponse HttpClient::fetch(const std::string& url) {
return response;
}
BinaryResponse HttpClient::fetch_binary(const std::string& url) {
BinaryResponse response;
response.status_code = 0;
if (!pImpl->curl) {
response.error_message = "CURL not initialized";
return response;
}
curl_easy_reset(pImpl->curl);
// 设置URL
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
// 设置写回调
std::vector<uint8_t> response_data;
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEFUNCTION, binary_write_callback);
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEDATA, &response_data);
// 设置超时
curl_easy_setopt(pImpl->curl, CURLOPT_TIMEOUT, pImpl->timeout);
curl_easy_setopt(pImpl->curl, CURLOPT_CONNECTTIMEOUT, 10L);
// 设置用户代理
curl_easy_setopt(pImpl->curl, CURLOPT_USERAGENT, pImpl->user_agent.c_str());
// 设置是否跟随重定向
if (pImpl->follow_redirects) {
curl_easy_setopt(pImpl->curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(pImpl->curl, CURLOPT_MAXREDIRS, 10L);
}
// 支持 HTTPS
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L);
// Cookie settings
if (!pImpl->cookie_file.empty()) {
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, pImpl->cookie_file.c_str());
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEJAR, pImpl->cookie_file.c_str());
} else {
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, "");
}
// 执行请求
CURLcode res = curl_easy_perform(pImpl->curl);
if (res != CURLE_OK) {
response.error_message = curl_easy_strerror(res);
return response;
}
// 获取响应码
long http_code = 0;
curl_easy_getinfo(pImpl->curl, CURLINFO_RESPONSE_CODE, &http_code);
response.status_code = static_cast<int>(http_code);
// 获取 Content-Type
char* content_type = nullptr;
curl_easy_getinfo(pImpl->curl, CURLINFO_CONTENT_TYPE, &content_type);
if (content_type) {
response.content_type = content_type;
}
response.data = std::move(response_data);
return response;
}
HttpResponse HttpClient::post(const std::string& url, const std::string& data,
const std::string& content_type) {
HttpResponse response;

View file

@ -1,6 +1,8 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
#include <memory>
struct HttpResponse {
@ -12,6 +14,21 @@ struct HttpResponse {
bool is_success() const {
return status_code >= 200 && status_code < 300;
}
bool is_image() const {
return content_type.find("image/") == 0;
}
};
struct BinaryResponse {
int status_code;
std::vector<uint8_t> data;
std::string content_type;
std::string error_message;
bool is_success() const {
return status_code >= 200 && status_code < 300;
}
};
class HttpClient {
@ -20,6 +37,7 @@ public:
~HttpClient();
HttpResponse fetch(const std::string& url);
BinaryResponse fetch_binary(const std::string& url);
HttpResponse post(const std::string& url, const std::string& data,
const std::string& content_type = "application/x-www-form-urlencoded");
void set_timeout(long timeout_seconds);

50
src/main_v2.cpp Normal file
View file

@ -0,0 +1,50 @@
#include "browser_v2.h"
#include <iostream>
#include <cstring>
void print_usage(const char* prog_name) {
std::cout << "TUT 2.0 - Terminal User Interface Browser\n"
<< "A vim-style terminal web browser with True Color support\n\n"
<< "Usage: " << prog_name << " [URL]\n\n"
<< "If no URL is provided, the browser will start with a help page.\n\n"
<< "Examples:\n"
<< " " << prog_name << "\n"
<< " " << prog_name << " https://example.com\n"
<< " " << prog_name << " https://news.ycombinator.com\n\n"
<< "Vim-style keybindings:\n"
<< " j/k - Scroll down/up\n"
<< " gg/G - Go to top/bottom\n"
<< " / - Search\n"
<< " Tab - Next link\n"
<< " Enter - Follow link\n"
<< " h/l - Back/Forward\n"
<< " :o URL - Open URL\n"
<< " :q - Quit\n"
<< " ? - Show help\n\n"
<< "New in 2.0:\n"
<< " - True Color (24-bit) support\n"
<< " - Improved Unicode handling\n"
<< " - Differential rendering for better performance\n";
}
int main(int argc, char* argv[]) {
std::string initial_url;
if (argc > 1) {
if (std::strcmp(argv[1], "-h") == 0 || std::strcmp(argv[1], "--help") == 0) {
print_usage(argv[0]);
return 0;
}
initial_url = argv[1];
}
try {
BrowserV2 browser;
browser.run(initial_url);
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}

116
src/render/colors.h Normal file
View file

@ -0,0 +1,116 @@
#pragma once
#include <cstdint>
namespace tut {
/**
* - True Color (24-bit RGB)
*
* 使
*/
namespace colors {
// ==================== 基础颜色 ====================
// 背景色
constexpr uint32_t BG_PRIMARY = 0x1A1A1A; // 主背景 - 深灰
constexpr uint32_t BG_SECONDARY = 0x252525; // 次背景 - 稍浅灰
constexpr uint32_t BG_ELEVATED = 0x2A2A2A; // 抬升背景 - 用于卡片/区块
constexpr uint32_t BG_SELECTION = 0x3A3A3A; // 选中背景
// 前景色
constexpr uint32_t FG_PRIMARY = 0xD0D0D0; // 主文本 - 浅灰
constexpr uint32_t FG_SECONDARY = 0x909090; // 次文本 - 中灰
constexpr uint32_t FG_DIM = 0x606060; // 暗淡文本
// ==================== 语义颜色 ====================
// 标题
constexpr uint32_t H1_FG = 0xE8C48C; // H1 - 暖金色
constexpr uint32_t H2_FG = 0x88C0D0; // H2 - 冰蓝色
constexpr uint32_t H3_FG = 0xA3BE8C; // H3 - 柔绿色
// 链接
constexpr uint32_t LINK_FG = 0x81A1C1; // 链接 - 柔蓝色
constexpr uint32_t LINK_ACTIVE = 0x88C0D0; // 活跃链接 - 亮蓝色
constexpr uint32_t LINK_VISITED = 0xB48EAD; // 已访问链接 - 柔紫色
// 表单元素
constexpr uint32_t INPUT_BG = 0x2E3440; // 输入框背景
constexpr uint32_t INPUT_BORDER = 0x4C566A; // 输入框边框
constexpr uint32_t INPUT_FOCUS = 0x5E81AC; // 聚焦边框
// 状态颜色
constexpr uint32_t SUCCESS = 0xA3BE8C; // 成功 - 绿色
constexpr uint32_t WARNING = 0xEBCB8B; // 警告 - 黄色
constexpr uint32_t ERROR = 0xBF616A; // 错误 - 红色
constexpr uint32_t INFO = 0x88C0D0; // 信息 - 蓝色
// ==================== UI元素颜色 ====================
// 状态栏
constexpr uint32_t STATUSBAR_BG = 0x2E3440; // 状态栏背景
constexpr uint32_t STATUSBAR_FG = 0xD8DEE9; // 状态栏文本
// URL栏
constexpr uint32_t URLBAR_BG = 0x3B4252; // URL栏背景
constexpr uint32_t URLBAR_FG = 0xECEFF4; // URL栏文本
// 搜索高亮
constexpr uint32_t SEARCH_MATCH_BG = 0x4C566A;
constexpr uint32_t SEARCH_MATCH_FG = 0xECEFF4;
constexpr uint32_t SEARCH_CURRENT_BG = 0x5E81AC;
constexpr uint32_t SEARCH_CURRENT_FG = 0xFFFFFF;
// 装饰元素
constexpr uint32_t BORDER = 0x4C566A; // 边框
constexpr uint32_t DIVIDER = 0x3B4252; // 分隔线
// 代码块
constexpr uint32_t CODE_BG = 0x2E3440; // 代码背景
constexpr uint32_t CODE_FG = 0xD8DEE9; // 代码文本
// 引用块
constexpr uint32_t QUOTE_BORDER = 0x4C566A; // 引用边框
constexpr uint32_t QUOTE_FG = 0x909090; // 引用文本
// 表格
constexpr uint32_t TABLE_BORDER = 0x4C566A;
constexpr uint32_t TABLE_HEADER_BG = 0x2E3440;
constexpr uint32_t TABLE_ROW_ALT = 0x252525; // 交替行
} // namespace colors
/**
* RGB辅助函数
*/
inline constexpr uint32_t rgb(uint8_t r, uint8_t g, uint8_t b) {
return (static_cast<uint32_t>(r) << 16) |
(static_cast<uint32_t>(g) << 8) |
static_cast<uint32_t>(b);
}
inline constexpr uint8_t get_red(uint32_t color) {
return (color >> 16) & 0xFF;
}
inline constexpr uint8_t get_green(uint32_t color) {
return (color >> 8) & 0xFF;
}
inline constexpr uint8_t get_blue(uint32_t color) {
return color & 0xFF;
}
/**
* 线
*/
inline uint32_t blend_colors(uint32_t c1, uint32_t c2, float t) {
uint8_t r = static_cast<uint8_t>(get_red(c1) * (1 - t) + get_red(c2) * t);
uint8_t g = static_cast<uint8_t>(get_green(c1) * (1 - t) + get_green(c2) * t);
uint8_t b = static_cast<uint8_t>(get_blue(c1) * (1 - t) + get_blue(c2) * t);
return rgb(r, g, b);
}
} // namespace tut

153
src/render/decorations.h Normal file
View file

@ -0,0 +1,153 @@
#pragma once
namespace tut {
/**
* Unicode装饰字符
*
*
*/
namespace chars {
// ==================== 框线字符 (Box Drawing) ====================
// 双线框
constexpr const char* DBL_HORIZONTAL = "";
constexpr const char* DBL_VERTICAL = "";
constexpr const char* DBL_TOP_LEFT = "";
constexpr const char* DBL_TOP_RIGHT = "";
constexpr const char* DBL_BOTTOM_LEFT = "";
constexpr const char* DBL_BOTTOM_RIGHT = "";
constexpr const char* DBL_T_DOWN = "";
constexpr const char* DBL_T_UP = "";
constexpr const char* DBL_T_RIGHT = "";
constexpr const char* DBL_T_LEFT = "";
constexpr const char* DBL_CROSS = "";
// 单线框
constexpr const char* SGL_HORIZONTAL = "";
constexpr const char* SGL_VERTICAL = "";
constexpr const char* SGL_TOP_LEFT = "";
constexpr const char* SGL_TOP_RIGHT = "";
constexpr const char* SGL_BOTTOM_LEFT = "";
constexpr const char* SGL_BOTTOM_RIGHT = "";
constexpr const char* SGL_T_DOWN = "";
constexpr const char* SGL_T_UP = "";
constexpr const char* SGL_T_RIGHT = "";
constexpr const char* SGL_T_LEFT = "";
constexpr const char* SGL_CROSS = "";
// 粗线框
constexpr const char* HEAVY_HORIZONTAL = "";
constexpr const char* HEAVY_VERTICAL = "";
constexpr const char* HEAVY_TOP_LEFT = "";
constexpr const char* HEAVY_TOP_RIGHT = "";
constexpr const char* HEAVY_BOTTOM_LEFT = "";
constexpr const char* HEAVY_BOTTOM_RIGHT= "";
// 圆角框
constexpr const char* ROUND_TOP_LEFT = "";
constexpr const char* ROUND_TOP_RIGHT = "";
constexpr const char* ROUND_BOTTOM_LEFT = "";
constexpr const char* ROUND_BOTTOM_RIGHT= "";
// ==================== 列表符号 ====================
constexpr const char* BULLET = "";
constexpr const char* BULLET_HOLLOW = "";
constexpr const char* BULLET_SQUARE = "";
constexpr const char* CIRCLE = "";
constexpr const char* SQUARE = "";
constexpr const char* TRIANGLE = "";
constexpr const char* DIAMOND = "";
constexpr const char* QUOTE_LEFT = "";
constexpr const char* ARROW = "";
constexpr const char* DASH = "";
constexpr const char* STAR = "";
constexpr const char* CHECK = "";
constexpr const char* CROSS = "";
// ==================== 箭头 ====================
constexpr const char* ARROW_RIGHT = "";
constexpr const char* ARROW_LEFT = "";
constexpr const char* ARROW_UP = "";
constexpr const char* ARROW_DOWN = "";
constexpr const char* ARROW_DOUBLE_RIGHT= "»";
constexpr const char* ARROW_DOUBLE_LEFT = "«";
// ==================== 装饰符号 ====================
constexpr const char* SECTION = "§";
constexpr const char* PARAGRAPH = "";
constexpr const char* ELLIPSIS = "";
constexpr const char* MIDDOT = "·";
constexpr const char* DEGREE = "°";
// ==================== 进度/状态 ====================
constexpr const char* BLOCK_FULL = "";
constexpr const char* BLOCK_3_4 = "";
constexpr const char* BLOCK_HALF = "";
constexpr const char* BLOCK_1_4 = "";
constexpr const char* SPINNER[] = {"", "", "", "", "", "", "", "", "", ""};
constexpr int SPINNER_FRAMES = 10;
// ==================== 分隔线样式 ====================
constexpr const char* HR_LIGHT = "";
constexpr const char* HR_HEAVY = "";
constexpr const char* HR_DOUBLE = "";
constexpr const char* HR_DASHED = "";
constexpr const char* HR_DOTTED = "";
} // namespace chars
/**
* 线
*/
inline std::string make_horizontal_line(int width, const char* ch = chars::SGL_HORIZONTAL) {
std::string result;
for (int i = 0; i < width; i++) {
result += ch;
}
return result;
}
/**
* 线
*/
struct BoxChars {
const char* top_left;
const char* top_right;
const char* bottom_left;
const char* bottom_right;
const char* horizontal;
const char* vertical;
};
constexpr BoxChars BOX_SINGLE = {
chars::SGL_TOP_LEFT, chars::SGL_TOP_RIGHT,
chars::SGL_BOTTOM_LEFT, chars::SGL_BOTTOM_RIGHT,
chars::SGL_HORIZONTAL, chars::SGL_VERTICAL
};
constexpr BoxChars BOX_DOUBLE = {
chars::DBL_TOP_LEFT, chars::DBL_TOP_RIGHT,
chars::DBL_BOTTOM_LEFT, chars::DBL_BOTTOM_RIGHT,
chars::DBL_HORIZONTAL, chars::DBL_VERTICAL
};
constexpr BoxChars BOX_HEAVY = {
chars::HEAVY_TOP_LEFT, chars::HEAVY_TOP_RIGHT,
chars::HEAVY_BOTTOM_LEFT, chars::HEAVY_BOTTOM_RIGHT,
chars::HEAVY_HORIZONTAL, chars::HEAVY_VERTICAL
};
constexpr BoxChars BOX_ROUND = {
chars::ROUND_TOP_LEFT, chars::ROUND_TOP_RIGHT,
chars::ROUND_BOTTOM_LEFT, chars::ROUND_BOTTOM_RIGHT,
chars::SGL_HORIZONTAL, chars::SGL_VERTICAL
};
} // namespace tut

265
src/render/image.cpp Normal file
View file

@ -0,0 +1,265 @@
#include "image.h"
#include <algorithm>
#include <cmath>
#include <fstream>
#include <sstream>
// 尝试加载stb_image如果存在
#if __has_include("../utils/stb_image.h")
#define STB_IMAGE_IMPLEMENTATION
#include "../utils/stb_image.h"
#define HAS_STB_IMAGE 1
#else
#define HAS_STB_IMAGE 0
#endif
// 简单的PPM格式解码器不需要外部库
static tut::ImageData decode_ppm(const std::vector<uint8_t>& data) {
tut::ImageData result;
if (data.size() < 10) return result;
// 检查PPM magic number
if (data[0] != 'P' || (data[1] != '6' && data[1] != '3')) {
return result;
}
std::string header(data.begin(), data.begin() + std::min(data.size(), size_t(256)));
std::istringstream iss(header);
std::string magic;
int width, height, max_val;
iss >> magic >> width >> height >> max_val;
if (width <= 0 || height <= 0 || max_val <= 0) return result;
result.width = width;
result.height = height;
result.channels = 4; // 输出RGBA
// 找到header结束位置
size_t header_end = iss.tellg();
while (header_end < data.size() && (data[header_end] == ' ' || data[header_end] == '\n')) {
header_end++;
}
if (data[1] == '6') {
// Binary PPM (P6)
size_t pixel_count = width * height;
result.pixels.resize(pixel_count * 4);
for (size_t i = 0; i < pixel_count && header_end + i * 3 + 2 < data.size(); ++i) {
result.pixels[i * 4 + 0] = data[header_end + i * 3 + 0]; // R
result.pixels[i * 4 + 1] = data[header_end + i * 3 + 1]; // G
result.pixels[i * 4 + 2] = data[header_end + i * 3 + 2]; // B
result.pixels[i * 4 + 3] = 255; // A
}
}
return result;
}
namespace tut {
// ==================== ImageRenderer ====================
ImageRenderer::ImageRenderer() = default;
AsciiImage ImageRenderer::render(const ImageData& data, int max_width, int max_height) {
AsciiImage result;
if (!data.is_valid()) {
return result;
}
// 计算缩放比例,保持宽高比
// 终端字符通常是2:1的高宽比所以height需要除以2
float aspect = static_cast<float>(data.width) / data.height;
int target_width = max_width;
int target_height = static_cast<int>(target_width / aspect / 2.0f);
if (target_height > max_height) {
target_height = max_height;
target_width = static_cast<int>(target_height * aspect * 2.0f);
}
target_width = std::max(1, std::min(target_width, max_width));
target_height = std::max(1, std::min(target_height, max_height));
// 缩放图片
ImageData scaled = resize(data, target_width, target_height);
result.width = target_width;
result.height = target_height;
result.lines.resize(target_height);
result.colors.resize(target_height);
for (int y = 0; y < target_height; ++y) {
result.lines[y].reserve(target_width);
result.colors[y].resize(target_width);
for (int x = 0; x < target_width; ++x) {
int idx = (y * target_width + x) * scaled.channels;
uint8_t r = scaled.pixels[idx];
uint8_t g = scaled.pixels[idx + 1];
uint8_t b = scaled.pixels[idx + 2];
uint8_t a = (scaled.channels == 4) ? scaled.pixels[idx + 3] : 255;
// 如果像素透明,使用空格
if (a < 128) {
result.lines[y] += ' ';
result.colors[y][x] = 0;
continue;
}
if (mode_ == Mode::ASCII) {
// ASCII模式使用亮度映射字符
int brightness = pixel_brightness(r, g, b);
result.lines[y] += brightness_to_char(brightness);
} else if (mode_ == Mode::BLOCKS) {
// 块模式:使用全块字符,颜色表示像素
result.lines[y] += "\u2588"; // █ 全块
} else {
// 默认使用块
result.lines[y] += "\u2588";
}
if (color_enabled_) {
result.colors[y][x] = rgb_to_color(r, g, b);
} else {
int brightness = pixel_brightness(r, g, b);
result.colors[y][x] = rgb_to_color(brightness, brightness, brightness);
}
}
}
return result;
}
ImageData ImageRenderer::load_from_file(const std::string& path) {
ImageData data;
#if HAS_STB_IMAGE
int width, height, channels;
unsigned char* pixels = stbi_load(path.c_str(), &width, &height, &channels, 4);
if (pixels) {
data.width = width;
data.height = height;
data.channels = 4;
data.pixels.assign(pixels, pixels + width * height * 4);
stbi_image_free(pixels);
}
#else
(void)path; // 未使用参数
#endif
return data;
}
ImageData ImageRenderer::load_from_memory(const std::vector<uint8_t>& buffer) {
ImageData data;
#if HAS_STB_IMAGE
int width, height, channels;
unsigned char* pixels = stbi_load_from_memory(
buffer.data(),
static_cast<int>(buffer.size()),
&width, &height, &channels, 4
);
if (pixels) {
data.width = width;
data.height = height;
data.channels = 4;
data.pixels.assign(pixels, pixels + width * height * 4);
stbi_image_free(pixels);
}
#else
// 尝试PPM格式解码
data = decode_ppm(buffer);
#endif
return data;
}
char ImageRenderer::brightness_to_char(int brightness) const {
// brightness: 0-255 -> 字符索引
int len = 10; // strlen(ASCII_CHARS)
int idx = (brightness * (len - 1)) / 255;
return ASCII_CHARS[idx];
}
uint32_t ImageRenderer::rgb_to_color(uint8_t r, uint8_t g, uint8_t b) {
return (static_cast<uint32_t>(r) << 16) |
(static_cast<uint32_t>(g) << 8) |
static_cast<uint32_t>(b);
}
int ImageRenderer::pixel_brightness(uint8_t r, uint8_t g, uint8_t b) {
// 使用加权平均计算亮度 (ITU-R BT.601)
return static_cast<int>(0.299f * r + 0.587f * g + 0.114f * b);
}
ImageData ImageRenderer::resize(const ImageData& src, int new_width, int new_height) {
ImageData dst;
dst.width = new_width;
dst.height = new_height;
dst.channels = src.channels;
dst.pixels.resize(new_width * new_height * src.channels);
float x_ratio = static_cast<float>(src.width) / new_width;
float y_ratio = static_cast<float>(src.height) / new_height;
for (int y = 0; y < new_height; ++y) {
for (int x = 0; x < new_width; ++x) {
// 双线性插值(简化版:最近邻)
int src_x = static_cast<int>(x * x_ratio);
int src_y = static_cast<int>(y * y_ratio);
src_x = std::min(src_x, src.width - 1);
src_y = std::min(src_y, src.height - 1);
int src_idx = (src_y * src.width + src_x) * src.channels;
int dst_idx = (y * new_width + x) * dst.channels;
for (int c = 0; c < src.channels; ++c) {
dst.pixels[dst_idx + c] = src.pixels[src_idx + c];
}
}
}
return dst;
}
// ==================== Helper Functions ====================
std::string make_image_placeholder(const std::string& alt_text, const std::string& src) {
std::string result = "[";
if (!alt_text.empty()) {
result += alt_text;
} else if (!src.empty()) {
// 从URL提取文件名
size_t last_slash = src.rfind('/');
if (last_slash != std::string::npos && last_slash + 1 < src.length()) {
std::string filename = src.substr(last_slash + 1);
// 去掉查询参数
size_t query = filename.find('?');
if (query != std::string::npos) {
filename = filename.substr(0, query);
}
result += "Image: " + filename;
} else {
result += "Image";
}
} else {
result += "Image";
}
result += "]";
return result;
}
} // namespace tut

110
src/render/image.h Normal file
View file

@ -0,0 +1,110 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
namespace tut {
/**
* ImageData -
*/
struct ImageData {
std::vector<uint8_t> pixels; // RGBA像素数据
int width = 0;
int height = 0;
int channels = 0; // 通道数 (3=RGB, 4=RGBA)
bool is_valid() const { return width > 0 && height > 0 && !pixels.empty(); }
};
/**
* AsciiImage - ASCII艺术渲染结果
*/
struct AsciiImage {
std::vector<std::string> lines; // 每行的ASCII字符
std::vector<std::vector<uint32_t>> colors; // 每个字符的颜色 (True Color)
int width = 0; // 字符宽度
int height = 0; // 字符高度
};
/**
* ImageRenderer -
*
* ASCII艺术或彩色块字符
*/
class ImageRenderer {
public:
/**
*
*/
enum class Mode {
ASCII, // 使用ASCII字符 (@#%*+=-:. )
BLOCKS, // 使用Unicode块字符 (▀▄█)
BRAILLE // 使用盲文点阵字符
};
ImageRenderer();
/**
* RGBA数据创建ASCII图像
* @param data
* @param max_width
* @param max_height
* @return ASCII渲染结果
*/
AsciiImage render(const ImageData& data, int max_width, int max_height);
/**
* (stb_image)
* @param path
* @return
*/
static ImageData load_from_file(const std::string& path);
/**
* (stb_image)
* @param data
* @return
*/
static ImageData load_from_memory(const std::vector<uint8_t>& data);
/**
*
*/
void set_mode(Mode mode) { mode_ = mode; }
/**
*
*/
void set_color_enabled(bool enabled) { color_enabled_ = enabled; }
private:
Mode mode_ = Mode::BLOCKS;
bool color_enabled_ = true;
// ASCII字符集 (按亮度从暗到亮)
static constexpr const char* ASCII_CHARS = " .:-=+*#%@";
// 将像素亮度映射到字符
char brightness_to_char(int brightness) const;
// 将RGB转换为True Color值
static uint32_t rgb_to_color(uint8_t r, uint8_t g, uint8_t b);
// 计算像素亮度
static int pixel_brightness(uint8_t r, uint8_t g, uint8_t b);
// 缩放图片
static ImageData resize(const ImageData& src, int new_width, int new_height);
};
/**
*
* @param alt_text
* @param src URL ()
* @return
*/
std::string make_image_placeholder(const std::string& alt_text, const std::string& src = "");
} // namespace tut

714
src/render/layout.cpp Normal file
View file

@ -0,0 +1,714 @@
#include "layout.h"
#include "decorations.h"
#include "image.h"
#include <sstream>
#include <algorithm>
namespace tut {
// ==================== LayoutEngine ====================
LayoutEngine::LayoutEngine(int viewport_width)
: viewport_width_(viewport_width)
, content_width_(viewport_width - MARGIN_LEFT - MARGIN_RIGHT)
{
}
LayoutResult LayoutEngine::layout(const DocumentTree& doc) {
LayoutResult result;
result.title = doc.title;
result.url = doc.url;
if (!doc.root) {
return result;
}
Context ctx;
layout_node(doc.root.get(), ctx, result.blocks);
// 计算总行数并收集链接和字段位置
int total = 0;
// 预分配位置数组
size_t num_links = doc.links.size();
size_t num_fields = doc.form_fields.size();
result.link_positions.resize(num_links, {-1, -1});
result.field_lines.resize(num_fields, -1);
for (const auto& block : result.blocks) {
total += block.margin_top;
for (const auto& line : block.lines) {
for (const auto& span : line.spans) {
// 记录链接位置
if (span.link_index >= 0 && span.link_index < static_cast<int>(num_links)) {
auto& pos = result.link_positions[span.link_index];
if (pos.start_line < 0) {
pos.start_line = total;
}
pos.end_line = total;
}
// 记录字段位置
if (span.field_index >= 0 && span.field_index < static_cast<int>(num_fields)) {
if (result.field_lines[span.field_index] < 0) {
result.field_lines[span.field_index] = total;
}
}
}
total++;
}
total += block.margin_bottom;
}
result.total_lines = total;
return result;
}
void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks) {
if (!node || !node->should_render()) {
return;
}
if (node->node_type == NodeType::DOCUMENT) {
for (const auto& child : node->children) {
layout_node(child.get(), ctx, blocks);
}
return;
}
// 处理容器元素html, body, div, form等- 递归处理子节点
if (node->tag_name == "html" || node->tag_name == "body" ||
node->tag_name == "head" || node->tag_name == "main" ||
node->tag_name == "article" || node->tag_name == "section" ||
node->tag_name == "div" || node->tag_name == "header" ||
node->tag_name == "footer" || node->tag_name == "nav" ||
node->tag_name == "aside" || node->tag_name == "form" ||
node->tag_name == "fieldset") {
for (const auto& child : node->children) {
layout_node(child.get(), ctx, blocks);
}
return;
}
// 处理表单内联元素
if (node->element_type == ElementType::INPUT ||
node->element_type == ElementType::BUTTON ||
node->element_type == ElementType::TEXTAREA ||
node->element_type == ElementType::SELECT) {
layout_form_element(node, ctx, blocks);
return;
}
// 处理图片元素
if (node->element_type == ElementType::IMAGE) {
layout_image_element(node, ctx, blocks);
return;
}
if (node->is_block_element()) {
layout_block_element(node, ctx, blocks);
}
// 内联元素在块级元素内部处理
}
void LayoutEngine::layout_block_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks) {
LayoutBlock block;
block.type = node->element_type;
// 设置边距
switch (node->element_type) {
case ElementType::HEADING1:
block.margin_top = 1;
block.margin_bottom = 1;
break;
case ElementType::HEADING2:
case ElementType::HEADING3:
block.margin_top = 1;
block.margin_bottom = 0;
break;
case ElementType::PARAGRAPH:
block.margin_top = 0;
block.margin_bottom = 1;
break;
case ElementType::LIST_ITEM:
case ElementType::ORDERED_LIST_ITEM:
block.margin_top = 0;
block.margin_bottom = 0;
break;
case ElementType::BLOCKQUOTE:
block.margin_top = 1;
block.margin_bottom = 1;
break;
case ElementType::CODE_BLOCK:
block.margin_top = 1;
block.margin_bottom = 1;
break;
case ElementType::HORIZONTAL_RULE:
block.margin_top = 1;
block.margin_bottom = 1;
break;
default:
block.margin_top = 0;
block.margin_bottom = 0;
break;
}
// 处理特殊块元素
if (node->element_type == ElementType::HORIZONTAL_RULE) {
// 水平线
LayoutLine line;
StyledSpan hr_span;
hr_span.text = make_horizontal_line(content_width_, chars::SGL_HORIZONTAL);
hr_span.fg = colors::DIVIDER;
line.spans.push_back(hr_span);
line.indent = MARGIN_LEFT;
block.lines.push_back(line);
blocks.push_back(block);
return;
}
// 检查是否是列表容器通过tag_name判断
if (node->tag_name == "ul" || node->tag_name == "ol") {
// 列表:递归处理子元素
ctx.list_depth++;
bool is_ordered = (node->tag_name == "ol");
if (is_ordered) {
ctx.ordered_list_counter = 1;
}
for (const auto& child : node->children) {
if (child->element_type == ElementType::LIST_ITEM ||
child->element_type == ElementType::ORDERED_LIST_ITEM) {
layout_block_element(child.get(), ctx, blocks);
if (is_ordered) {
ctx.ordered_list_counter++;
}
}
}
ctx.list_depth--;
return;
}
if (node->element_type == ElementType::BLOCKQUOTE) {
ctx.in_blockquote = true;
}
if (node->element_type == ElementType::CODE_BLOCK) {
ctx.in_pre = true;
}
// 收集内联内容
std::vector<StyledSpan> spans;
// 列表项的标记
if (node->element_type == ElementType::LIST_ITEM) {
StyledSpan marker;
marker.text = get_list_marker(ctx.list_depth, ctx.ordered_list_counter > 0, ctx.ordered_list_counter);
marker.fg = colors::FG_SECONDARY;
spans.push_back(marker);
}
collect_inline_content(node, ctx, spans);
if (node->element_type == ElementType::BLOCKQUOTE) {
ctx.in_blockquote = false;
}
if (node->element_type == ElementType::CODE_BLOCK) {
ctx.in_pre = false;
}
// 计算缩进
int indent = MARGIN_LEFT;
if (ctx.list_depth > 0) {
indent += ctx.list_depth * 2;
}
if (ctx.in_blockquote) {
indent += 2;
}
// 换行
int available_width = content_width_ - (indent - MARGIN_LEFT);
if (ctx.in_pre) {
// 预格式化文本不换行
for (const auto& span : spans) {
LayoutLine line;
line.indent = indent;
line.spans.push_back(span);
block.lines.push_back(line);
}
} else {
block.lines = wrap_text(spans, available_width, indent);
}
// 引用块添加边框
if (node->element_type == ElementType::BLOCKQUOTE && !block.lines.empty()) {
for (auto& line : block.lines) {
StyledSpan border;
border.text = chars::QUOTE_LEFT + std::string(" ");
border.fg = colors::QUOTE_BORDER;
line.spans.insert(line.spans.begin(), border);
}
}
if (!block.lines.empty()) {
blocks.push_back(block);
}
// 处理子块元素
for (const auto& child : node->children) {
if (child->is_block_element()) {
layout_node(child.get(), ctx, blocks);
}
}
}
void LayoutEngine::layout_form_element(const DomNode* node, Context& /*ctx*/, std::vector<LayoutBlock>& blocks) {
LayoutBlock block;
block.type = node->element_type;
block.margin_top = 0;
block.margin_bottom = 0;
LayoutLine line;
line.indent = MARGIN_LEFT;
if (node->element_type == ElementType::INPUT) {
// 渲染输入框
std::string input_type = node->input_type;
if (input_type == "submit" || input_type == "button") {
// 按钮样式: [ Submit ]
std::string label = node->value.empty() ? "Submit" : node->value;
StyledSpan span;
span.text = "[ " + label + " ]";
span.fg = colors::INPUT_FOCUS;
span.bg = colors::INPUT_BG;
span.attrs = ATTR_BOLD;
span.field_index = node->field_index;
line.spans.push_back(span);
} else if (input_type == "checkbox") {
// 复选框: [x] 或 [ ]
StyledSpan span;
span.text = node->checked ? "[x]" : "[ ]";
span.fg = colors::INPUT_FOCUS;
span.field_index = node->field_index;
line.spans.push_back(span);
// 添加标签如果有name
if (!node->name.empty()) {
StyledSpan label;
label.text = " " + node->name;
label.fg = colors::FG_PRIMARY;
line.spans.push_back(label);
}
} else if (input_type == "radio") {
// 单选框: (o) 或 ( )
StyledSpan span;
span.text = node->checked ? "(o)" : "( )";
span.fg = colors::INPUT_FOCUS;
span.field_index = node->field_index;
line.spans.push_back(span);
if (!node->name.empty()) {
StyledSpan label;
label.text = " " + node->name;
label.fg = colors::FG_PRIMARY;
line.spans.push_back(label);
}
} else {
// 文本输入框: [placeholder____]
std::string display_text;
if (!node->value.empty()) {
display_text = node->value;
} else if (!node->placeholder.empty()) {
display_text = node->placeholder;
} else {
display_text = "";
}
// 限制显示宽度
int field_width = 20;
if (display_text.length() > static_cast<size_t>(field_width)) {
display_text = display_text.substr(0, field_width - 1) + "";
} else {
display_text += std::string(field_width - display_text.length(), '_');
}
StyledSpan span;
span.text = "[" + display_text + "]";
span.fg = node->value.empty() ? colors::FG_DIM : colors::FG_PRIMARY;
span.bg = colors::INPUT_BG;
span.field_index = node->field_index;
line.spans.push_back(span);
}
} else if (node->element_type == ElementType::BUTTON) {
// 按钮
std::string label = node->get_all_text();
if (label.empty()) {
label = node->value.empty() ? "Button" : node->value;
}
StyledSpan span;
span.text = "[ " + label + " ]";
span.fg = colors::INPUT_FOCUS;
span.bg = colors::INPUT_BG;
span.attrs = ATTR_BOLD;
span.field_index = node->field_index;
line.spans.push_back(span);
} else if (node->element_type == ElementType::TEXTAREA) {
// 文本区域
std::string content = node->value.empty() ? node->placeholder : node->value;
if (content.empty()) {
content = "(empty)";
}
StyledSpan span;
span.text = "[" + content + "]";
span.fg = colors::FG_PRIMARY;
span.bg = colors::INPUT_BG;
span.field_index = node->field_index;
line.spans.push_back(span);
} else if (node->element_type == ElementType::SELECT) {
// 下拉选择
StyledSpan span;
span.text = "[▼ Select]";
span.fg = colors::INPUT_FOCUS;
span.bg = colors::INPUT_BG;
span.field_index = node->field_index;
line.spans.push_back(span);
}
if (!line.spans.empty()) {
block.lines.push_back(line);
blocks.push_back(block);
}
}
void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, std::vector<LayoutBlock>& blocks) {
LayoutBlock block;
block.type = ElementType::IMAGE;
block.margin_top = 0;
block.margin_bottom = 1;
LayoutLine line;
line.indent = MARGIN_LEFT;
// 生成图片占位符
std::string placeholder = make_image_placeholder(node->alt_text, node->img_src);
StyledSpan span;
span.text = placeholder;
span.fg = colors::FG_DIM; // 使用较暗的颜色表示占位符
span.attrs = ATTR_NONE;
line.spans.push_back(span);
block.lines.push_back(line);
blocks.push_back(block);
}
void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
if (!node) return;
if (node->node_type == NodeType::TEXT) {
layout_text(node, ctx, spans);
return;
}
if (node->is_inline_element() || node->node_type == NodeType::ELEMENT) {
// 设置样式
uint32_t fg = get_element_fg_color(node->element_type);
uint8_t attrs = get_element_attrs(node->element_type);
// 处理链接
int link_idx = node->link_index;
// 递归处理子节点
for (const auto& child : node->children) {
if (child->node_type == NodeType::TEXT) {
StyledSpan span;
span.text = child->text_content;
span.fg = fg;
span.attrs = attrs;
span.link_index = link_idx;
if (ctx.in_blockquote) {
span.fg = colors::QUOTE_FG;
}
spans.push_back(span);
} else if (!child->is_block_element()) {
collect_inline_content(child.get(), ctx, spans);
}
}
}
}
void LayoutEngine::layout_text(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
if (!node || node->text_content.empty()) return;
StyledSpan span;
span.text = node->text_content;
span.fg = colors::FG_PRIMARY;
if (ctx.in_blockquote) {
span.fg = colors::QUOTE_FG;
}
spans.push_back(span);
}
std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& spans, int available_width, int indent) {
std::vector<LayoutLine> lines;
if (spans.empty()) {
return lines;
}
LayoutLine current_line;
current_line.indent = indent;
size_t current_width = 0;
for (const auto& span : spans) {
// 分词处理
std::istringstream iss(span.text);
std::string word;
bool first_word = true;
while (iss >> word) {
size_t word_width = Unicode::display_width(word);
// 检查是否需要换行
if (current_width > 0 && current_width + 1 + word_width > static_cast<size_t>(available_width)) {
// 当前行已满,开始新行
if (!current_line.spans.empty()) {
lines.push_back(current_line);
}
current_line = LayoutLine();
current_line.indent = indent;
current_width = 0;
first_word = true;
}
// 添加空格(如果不是行首)
if (current_width > 0 && !first_word) {
if (!current_line.spans.empty()) {
current_line.spans.back().text += " ";
current_width += 1;
}
}
// 添加单词
StyledSpan word_span = span;
word_span.text = word;
current_line.spans.push_back(word_span);
current_width += word_width;
first_word = false;
}
}
// 添加最后一行
if (!current_line.spans.empty()) {
lines.push_back(current_line);
}
return lines;
}
uint32_t LayoutEngine::get_element_fg_color(ElementType type) const {
switch (type) {
case ElementType::HEADING1:
return colors::H1_FG;
case ElementType::HEADING2:
return colors::H2_FG;
case ElementType::HEADING3:
case ElementType::HEADING4:
case ElementType::HEADING5:
case ElementType::HEADING6:
return colors::H3_FG;
case ElementType::LINK:
return colors::LINK_FG;
case ElementType::CODE_BLOCK:
return colors::CODE_FG;
case ElementType::BLOCKQUOTE:
return colors::QUOTE_FG;
default:
return colors::FG_PRIMARY;
}
}
uint8_t LayoutEngine::get_element_attrs(ElementType type) const {
switch (type) {
case ElementType::HEADING1:
case ElementType::HEADING2:
case ElementType::HEADING3:
case ElementType::HEADING4:
case ElementType::HEADING5:
case ElementType::HEADING6:
return ATTR_BOLD;
case ElementType::LINK:
return ATTR_UNDERLINE;
default:
return ATTR_NONE;
}
}
std::string LayoutEngine::get_list_marker(int depth, bool ordered, int counter) const {
if (ordered) {
return std::to_string(counter) + ". ";
}
// 不同层级使用不同的标记
switch ((depth - 1) % 3) {
case 0: return std::string(chars::BULLET) + " ";
case 1: return std::string(chars::BULLET_HOLLOW) + " ";
case 2: return std::string(chars::BULLET_SQUARE) + " ";
default: return std::string(chars::BULLET) + " ";
}
}
// ==================== DocumentRenderer ====================
DocumentRenderer::DocumentRenderer(FrameBuffer& buffer)
: buffer_(buffer)
{
}
void DocumentRenderer::render(const LayoutResult& layout, int scroll_offset, const RenderContext& ctx) {
int buffer_height = buffer_.height();
int y = 0; // 缓冲区行位置
int doc_line = 0; // 文档行位置
for (const auto& block : layout.blocks) {
// 处理上边距
for (int i = 0; i < block.margin_top; ++i) {
if (doc_line >= scroll_offset && y < buffer_height) {
// 空行
y++;
}
doc_line++;
}
// 渲染内容行
for (const auto& line : block.lines) {
if (doc_line >= scroll_offset) {
if (y >= buffer_height) {
return; // 超出视口
}
render_line(line, y, doc_line, ctx);
y++;
}
doc_line++;
}
// 处理下边距
for (int i = 0; i < block.margin_bottom; ++i) {
if (doc_line >= scroll_offset && y < buffer_height) {
// 空行
y++;
}
doc_line++;
}
}
}
int DocumentRenderer::find_match_at(const SearchContext* search, int doc_line, int col) const {
if (!search || !search->enabled || search->matches.empty()) {
return -1;
}
for (size_t i = 0; i < search->matches.size(); ++i) {
const auto& m = search->matches[i];
if (m.line == doc_line && col >= m.start_col && col < m.start_col + m.length) {
return static_cast<int>(i);
}
}
return -1;
}
void DocumentRenderer::render_line(const LayoutLine& line, int y, int doc_line, const RenderContext& ctx) {
int x = line.indent;
for (const auto& span : line.spans) {
// 检查是否需要搜索高亮
bool has_search_match = (ctx.search && ctx.search->enabled && !ctx.search->matches.empty());
if (has_search_match) {
// 按字符渲染以支持部分高亮
const std::string& text = span.text;
int char_col = x;
for (size_t i = 0; i < text.size(); ) {
// 获取字符宽度处理UTF-8
int char_bytes = 1;
unsigned char c = text[i];
if ((c & 0x80) == 0) {
char_bytes = 1;
} else if ((c & 0xE0) == 0xC0) {
char_bytes = 2;
} else if ((c & 0xF0) == 0xE0) {
char_bytes = 3;
} else if ((c & 0xF8) == 0xF0) {
char_bytes = 4;
}
std::string ch = text.substr(i, char_bytes);
int char_width = static_cast<int>(Unicode::display_width(ch));
uint32_t fg = span.fg;
uint32_t bg = span.bg;
uint8_t attrs = span.attrs;
// 检查搜索匹配
int match_idx = find_match_at(ctx.search, doc_line, char_col);
if (match_idx >= 0) {
// 搜索高亮
if (match_idx == ctx.search->current_match_idx) {
fg = colors::SEARCH_CURRENT_FG;
bg = colors::SEARCH_CURRENT_BG;
} else {
fg = colors::SEARCH_MATCH_FG;
bg = colors::SEARCH_MATCH_BG;
}
attrs |= ATTR_BOLD;
} else if (span.link_index >= 0 && span.link_index == ctx.active_link) {
// 活跃链接高亮
fg = colors::LINK_ACTIVE;
attrs |= ATTR_BOLD;
} else if (span.field_index >= 0 && span.field_index == ctx.active_field) {
// 活跃表单字段高亮
fg = colors::SEARCH_CURRENT_FG;
bg = colors::INPUT_FOCUS;
attrs |= ATTR_BOLD;
}
buffer_.set_text(char_col, y, ch, fg, bg, attrs);
char_col += char_width;
i += char_bytes;
}
x = char_col;
} else {
// 无搜索匹配时,整体渲染(更高效)
uint32_t fg = span.fg;
uint32_t bg = span.bg;
uint8_t attrs = span.attrs;
// 高亮活跃链接
if (span.link_index >= 0 && span.link_index == ctx.active_link) {
fg = colors::LINK_ACTIVE;
attrs |= ATTR_BOLD;
}
// 高亮活跃表单字段
else if (span.field_index >= 0 && span.field_index == ctx.active_field) {
fg = colors::SEARCH_CURRENT_FG;
bg = colors::INPUT_FOCUS;
attrs |= ATTR_BOLD;
}
buffer_.set_text(x, y, span.text, fg, bg, attrs);
x += static_cast<int>(span.display_width());
}
}
}
} // namespace tut

197
src/render/layout.h Normal file
View file

@ -0,0 +1,197 @@
#pragma once
#include "renderer.h"
#include "colors.h"
#include "../dom_tree.h"
#include "../utils/unicode.h"
#include <vector>
#include <string>
#include <memory>
namespace tut {
/**
* StyledSpan -
*
*
*/
struct StyledSpan {
std::string text;
uint32_t fg = colors::FG_PRIMARY;
uint32_t bg = colors::BG_PRIMARY;
uint8_t attrs = ATTR_NONE;
int link_index = -1; // -1表示非链接
int field_index = -1; // -1表示非表单字段
size_t display_width() const {
return Unicode::display_width(text);
}
};
/**
* LayoutLine -
*
* StyledSpan组成
*/
struct LayoutLine {
std::vector<StyledSpan> spans;
int indent = 0; // 行首缩进(字符数)
bool is_blank = false;
size_t total_width() const {
size_t width = indent;
for (const auto& span : spans) {
width += span.display_width();
}
return width;
}
};
/**
* LayoutBlock -
*
*
*
*/
struct LayoutBlock {
std::vector<LayoutLine> lines;
int margin_top = 0; // 上边距(行数)
int margin_bottom = 0; // 下边距(行数)
ElementType type = ElementType::PARAGRAPH;
};
/**
* LinkPosition -
*/
struct LinkPosition {
int start_line; // 起始行
int end_line; // 结束行(可能跨多行)
};
/**
* LayoutResult -
*
*
*/
struct LayoutResult {
std::vector<LayoutBlock> blocks;
int total_lines = 0; // 总行数(包括边距)
std::string title;
std::string url;
// 链接位置映射 (link_index -> LinkPosition)
std::vector<LinkPosition> link_positions;
// 表单字段位置映射 (field_index -> line_number)
std::vector<int> field_lines;
};
/**
* LayoutEngine -
*
* DOM树转换为布局结果
*/
class LayoutEngine {
public:
explicit LayoutEngine(int viewport_width);
/**
*
*/
LayoutResult layout(const DocumentTree& doc);
/**
*
*/
void set_viewport_width(int width) { viewport_width_ = width; }
private:
int viewport_width_;
int content_width_; // 实际内容宽度(视口宽度减去边距)
static constexpr int MARGIN_LEFT = 2;
static constexpr int MARGIN_RIGHT = 2;
// 布局上下文
struct Context {
int list_depth = 0;
int ordered_list_counter = 0;
bool in_blockquote = false;
bool in_pre = false;
};
// 布局处理方法
void layout_node(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks);
void layout_block_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks);
void layout_form_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks);
void layout_image_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks);
void layout_text(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans);
// 收集内联内容
void collect_inline_content(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans);
// 文本换行
std::vector<LayoutLine> wrap_text(const std::vector<StyledSpan>& spans, int available_width, int indent = 0);
// 获取元素样式
uint32_t get_element_fg_color(ElementType type) const;
uint8_t get_element_attrs(ElementType type) const;
// 获取列表标记
std::string get_list_marker(int depth, bool ordered, int counter) const;
};
/**
* SearchMatch -
*/
struct SearchMatch {
int line; // 文档行号
int start_col; // 行内起始列
int length; // 匹配长度
};
/**
* SearchContext -
*/
struct SearchContext {
std::vector<SearchMatch> matches;
int current_match_idx = -1; // 当前高亮的匹配索引
bool enabled = false;
};
/**
* RenderContext -
*/
struct RenderContext {
int active_link = -1; // 当前活跃链接索引
int active_field = -1; // 当前活跃表单字段索引
const SearchContext* search = nullptr; // 搜索上下文
};
/**
* DocumentRenderer -
*
* LayoutResult渲染到FrameBuffer
*/
class DocumentRenderer {
public:
explicit DocumentRenderer(FrameBuffer& buffer);
/**
*
*
* @param layout
* @param scroll_offset
* @param ctx
*/
void render(const LayoutResult& layout, int scroll_offset, const RenderContext& ctx = {});
private:
FrameBuffer& buffer_;
void render_line(const LayoutLine& line, int y, int doc_line, const RenderContext& ctx);
// 检查位置是否在搜索匹配中
int find_match_at(const SearchContext* search, int doc_line, int col) const;
};
} // namespace tut

227
src/render/renderer.cpp Normal file
View file

@ -0,0 +1,227 @@
#include "renderer.h"
#include "../utils/unicode.h"
namespace tut {
// ============================================================================
// FrameBuffer Implementation
// ============================================================================
FrameBuffer::FrameBuffer(int width, int height)
: width_(width), height_(height) {
empty_cell_.content = " ";
resize(width, height);
}
void FrameBuffer::resize(int width, int height) {
width_ = width;
height_ = height;
cells_.resize(height);
for (auto& row : cells_) {
row.resize(width, empty_cell_);
}
}
void FrameBuffer::clear() {
for (auto& row : cells_) {
std::fill(row.begin(), row.end(), empty_cell_);
}
}
void FrameBuffer::clear_with_color(uint32_t bg) {
Cell cell = empty_cell_;
cell.bg = bg;
for (auto& row : cells_) {
std::fill(row.begin(), row.end(), cell);
}
}
void FrameBuffer::set_cell(int x, int y, const Cell& cell) {
if (x >= 0 && x < width_ && y >= 0 && y < height_) {
cells_[y][x] = cell;
}
}
const Cell& FrameBuffer::get_cell(int x, int y) const {
if (x >= 0 && x < width_ && y >= 0 && y < height_) {
return cells_[y][x];
}
return empty_cell_;
}
void FrameBuffer::set_text(int x, int y, const std::string& text,
uint32_t fg, uint32_t bg, uint8_t attrs) {
if (y < 0 || y >= height_) return;
size_t i = 0;
int cur_x = x;
while (i < text.length() && cur_x < width_) {
if (cur_x < 0) {
// Skip characters before visible area
i += Unicode::char_byte_length(text, i);
cur_x++;
continue;
}
size_t byte_len = Unicode::char_byte_length(text, i);
std::string ch = text.substr(i, byte_len);
// Determine character width
size_t char_width = 1;
unsigned char c = text[i];
if ((c & 0xF0) == 0xE0 || (c & 0xF8) == 0xF0) {
char_width = 2; // CJK or emoji
}
Cell cell;
cell.content = ch;
cell.fg = fg;
cell.bg = bg;
cell.attrs = attrs;
set_cell(cur_x, y, cell);
// For wide characters, mark next cell as placeholder
if (char_width == 2 && cur_x + 1 < width_) {
Cell placeholder;
placeholder.content = ""; // Empty = continuation of previous cell
placeholder.fg = fg;
placeholder.bg = bg;
placeholder.attrs = attrs;
set_cell(cur_x + 1, y, placeholder);
}
cur_x += char_width;
i += byte_len;
}
}
// ============================================================================
// Renderer Implementation
// ============================================================================
Renderer::Renderer(Terminal& terminal)
: terminal_(terminal), prev_buffer_(1, 1) {
int w, h;
terminal_.get_size(w, h);
prev_buffer_.resize(w, h);
}
void Renderer::render(const FrameBuffer& buffer) {
int w = buffer.width();
int h = buffer.height();
// Check if resize needed
if (prev_buffer_.width() != w || prev_buffer_.height() != h) {
prev_buffer_.resize(w, h);
need_full_redraw_ = true;
}
terminal_.hide_cursor();
uint32_t last_fg = 0xFFFFFFFF; // Invalid color to force first set
uint32_t last_bg = 0xFFFFFFFF;
uint8_t last_attrs = 0xFF;
int last_x = -2;
// 批量输出缓冲
std::string batch_text;
int batch_start_x = 0;
int batch_y = 0;
uint32_t batch_fg = 0;
uint32_t batch_bg = 0;
uint8_t batch_attrs = 0;
auto flush_batch = [&]() {
if (batch_text.empty()) return;
terminal_.move_cursor(batch_start_x, batch_y);
if (batch_fg != last_fg) {
terminal_.set_foreground(batch_fg);
last_fg = batch_fg;
}
if (batch_bg != last_bg) {
terminal_.set_background(batch_bg);
last_bg = batch_bg;
}
if (batch_attrs != last_attrs) {
terminal_.reset_attributes();
if (batch_attrs & ATTR_BOLD) terminal_.set_bold(true);
if (batch_attrs & ATTR_ITALIC) terminal_.set_italic(true);
if (batch_attrs & ATTR_UNDERLINE) terminal_.set_underline(true);
if (batch_attrs & ATTR_REVERSE) terminal_.set_reverse(true);
if (batch_attrs & ATTR_DIM) terminal_.set_dim(true);
last_attrs = batch_attrs;
terminal_.set_foreground(batch_fg);
terminal_.set_background(batch_bg);
}
terminal_.print(batch_text);
batch_text.clear();
};
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
const Cell& cell = buffer.get_cell(x, y);
const Cell& prev = prev_buffer_.get_cell(x, y);
// Skip if unchanged and not forcing redraw
if (!need_full_redraw_ && cell == prev) {
flush_batch();
last_x = -2;
continue;
}
// Skip placeholder cells (continuation of wide chars)
if (cell.content.empty()) {
continue;
}
// 检查是否可以添加到批量输出
bool can_batch = (y == batch_y) &&
(x == last_x + 1 || batch_text.empty()) &&
(cell.fg == batch_fg || batch_text.empty()) &&
(cell.bg == batch_bg || batch_text.empty()) &&
(cell.attrs == batch_attrs || batch_text.empty());
if (!can_batch) {
flush_batch();
batch_start_x = x;
batch_y = y;
batch_fg = cell.fg;
batch_bg = cell.bg;
batch_attrs = cell.attrs;
}
batch_text += cell.content;
last_x = x;
}
// 行末刷新
flush_batch();
last_x = -2;
}
flush_batch();
terminal_.reset_colors();
terminal_.reset_attributes();
terminal_.refresh();
// Copy current buffer to previous for next diff
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
const_cast<FrameBuffer&>(prev_buffer_).set_cell(x, y, buffer.get_cell(x, y));
}
}
need_full_redraw_ = false;
}
void Renderer::force_redraw() {
need_full_redraw_ = true;
}
} // namespace tut

103
src/render/renderer.h Normal file
View file

@ -0,0 +1,103 @@
#pragma once
#include "terminal.h"
#include <vector>
#include <string>
#include <cstdint>
namespace tut {
/**
*
*/
enum CellAttr : uint8_t {
ATTR_NONE = 0,
ATTR_BOLD = 1 << 0,
ATTR_ITALIC = 1 << 1,
ATTR_UNDERLINE = 1 << 2,
ATTR_REVERSE = 1 << 3,
ATTR_DIM = 1 << 4
};
/**
* Cell -
*
* UTF-8
*/
struct Cell {
std::string content; // UTF-8字符可能1-4字节
uint32_t fg = 0xD0D0D0; // 前景色 (默认浅灰)
uint32_t bg = 0x1A1A1A; // 背景色 (默认深灰)
uint8_t attrs = ATTR_NONE;
bool operator==(const Cell& other) const {
return content == other.content &&
fg == other.fg &&
bg == other.bg &&
attrs == other.attrs;
}
bool operator!=(const Cell& other) const {
return !(*this == other);
}
};
/**
* FrameBuffer -
*
*
*/
class FrameBuffer {
public:
FrameBuffer(int width, int height);
void resize(int width, int height);
void clear();
void clear_with_color(uint32_t bg);
void set_cell(int x, int y, const Cell& cell);
const Cell& get_cell(int x, int y) const;
// 便捷方法:设置文本(处理宽字符)
void set_text(int x, int y, const std::string& text, uint32_t fg, uint32_t bg, uint8_t attrs = ATTR_NONE);
int width() const { return width_; }
int height() const { return height_; }
private:
std::vector<std::vector<Cell>> cells_;
int width_;
int height_;
Cell empty_cell_;
};
/**
* Renderer -
*
* FrameBuffer的内容渲染到终端
*
*/
class Renderer {
public:
explicit Renderer(Terminal& terminal);
/**
*
* 使
*/
void render(const FrameBuffer& buffer);
/**
*
*/
void force_redraw();
private:
Terminal& terminal_;
FrameBuffer prev_buffer_; // 上一帧,用于差分渲染
bool need_full_redraw_ = true;
void apply_cell_style(const Cell& cell);
};
} // namespace tut

410
src/render/terminal.cpp Normal file
View file

@ -0,0 +1,410 @@
#include "terminal.h"
#include <ncurses.h>
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <locale.h>
namespace tut {
// ==================== Terminal::Impl ====================
class Terminal::Impl {
public:
Impl()
: initialized_(false)
, has_true_color_(false)
, has_mouse_(false)
, has_unicode_(false)
, has_italic_(false)
, width_(0)
, height_(0)
, mouse_enabled_(false)
{}
~Impl() {
if (initialized_) {
cleanup();
}
}
bool init() {
if (initialized_) {
return true;
}
// 设置locale以支持UTF-8
setlocale(LC_ALL, "");
// 初始化ncurses
initscr();
if (stdscr == nullptr) {
return false;
}
// 基础设置
raw(); // 禁用行缓冲
noecho(); // 不回显输入
keypad(stdscr, TRUE); // 启用功能键
nodelay(stdscr, TRUE); // 非阻塞输入(默认)
// 检测终端能力
detect_capabilities();
// 获取屏幕尺寸
getmaxyx(stdscr, height_, width_);
// 隐藏光标(默认)
curs_set(0);
// 启用鼠标支持
if (has_mouse_) {
enable_mouse(true);
}
// 使用替代屏幕缓冲区
use_alternate_screen(true);
initialized_ = true;
return true;
}
void cleanup() {
if (!initialized_) {
return;
}
// 恢复光标
curs_set(1);
// 禁用鼠标
if (mouse_enabled_) {
enable_mouse(false);
}
// 退出替代屏幕
use_alternate_screen(false);
// 清理ncurses
endwin();
initialized_ = false;
}
void detect_capabilities() {
// 检测True Color支持
const char* colorterm = std::getenv("COLORTERM");
has_true_color_ = (colorterm != nullptr &&
(std::strcmp(colorterm, "truecolor") == 0 ||
std::strcmp(colorterm, "24bit") == 0));
// 检测鼠标支持
has_mouse_ = has_mouse();
// 检测Unicode支持通过locale
const char* lang = std::getenv("LANG");
has_unicode_ = (lang != nullptr &&
(std::strstr(lang, "UTF-8") != nullptr ||
std::strstr(lang, "utf8") != nullptr));
// 检测斜体支持(大多数现代终端支持)
const char* term = std::getenv("TERM");
has_italic_ = (term != nullptr &&
(std::strstr(term, "xterm") != nullptr ||
std::strstr(term, "screen") != nullptr ||
std::strstr(term, "tmux") != nullptr ||
std::strstr(term, "kitty") != nullptr ||
std::strstr(term, "alacritty") != nullptr));
}
void get_size(int& width, int& height) {
// 每次调用时获取最新尺寸,以支持窗口大小调整
getmaxyx(stdscr, height_, width_);
width = width_;
height = height_;
}
void clear() {
::clear();
}
void refresh() {
::refresh();
}
// ==================== True Color ====================
void set_foreground(uint32_t rgb) {
if (has_true_color_) {
// ANSI escape: ESC[38;2;R;G;Bm
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
std::printf("\033[38;2;%d;%d;%dm", r, g, b);
std::fflush(stdout);
} else {
// 降级到基础色(简化映射)
// 这里可以实现256色或8色的映射
// 暂时使用默认色
}
}
void set_background(uint32_t rgb) {
if (has_true_color_) {
// ANSI escape: ESC[48;2;R;G;Bm
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
std::printf("\033[48;2;%d;%d;%dm", r, g, b);
std::fflush(stdout);
}
}
void reset_colors() {
// ESC[39m 重置前景色, ESC[49m 重置背景色
std::printf("\033[39m\033[49m");
std::fflush(stdout);
}
// ==================== 文本属性 ====================
void set_bold(bool enabled) {
if (enabled) {
std::printf("\033[1m"); // ESC[1m
} else {
std::printf("\033[22m"); // ESC[22m (normal intensity)
}
std::fflush(stdout);
}
void set_italic(bool enabled) {
if (!has_italic_) return;
if (enabled) {
std::printf("\033[3m"); // ESC[3m
} else {
std::printf("\033[23m"); // ESC[23m
}
std::fflush(stdout);
}
void set_underline(bool enabled) {
if (enabled) {
std::printf("\033[4m"); // ESC[4m
} else {
std::printf("\033[24m"); // ESC[24m
}
std::fflush(stdout);
}
void set_reverse(bool enabled) {
if (enabled) {
std::printf("\033[7m"); // ESC[7m
} else {
std::printf("\033[27m"); // ESC[27m
}
std::fflush(stdout);
}
void set_dim(bool enabled) {
if (enabled) {
std::printf("\033[2m"); // ESC[2m
} else {
std::printf("\033[22m"); // ESC[22m
}
std::fflush(stdout);
}
void reset_attributes() {
std::printf("\033[0m"); // ESC[0m (reset all)
std::fflush(stdout);
}
// ==================== 光标控制 ====================
void move_cursor(int x, int y) {
move(y, x); // ncurses使用 (y, x) 顺序
}
void hide_cursor() {
curs_set(0);
}
void show_cursor() {
curs_set(1);
}
// ==================== 文本输出 ====================
void print(const std::string& text) {
// 直接输出到stdout配合ANSI escape sequences
std::printf("%s", text.c_str());
std::fflush(stdout);
}
void print_at(int x, int y, const std::string& text) {
move_cursor(x, y);
print(text);
}
// ==================== 输入处理 ====================
int get_key(int timeout_ms) {
if (timeout_ms == -1) {
// 阻塞等待
nodelay(stdscr, FALSE);
int ch = getch();
nodelay(stdscr, TRUE);
return ch;
} else if (timeout_ms == 0) {
// 非阻塞
return getch();
} else {
// 超时等待
timeout(timeout_ms);
int ch = getch();
nodelay(stdscr, TRUE);
return ch;
}
}
bool get_mouse_event(MouseEvent& event) {
if (!mouse_enabled_) {
return false;
}
MEVENT mevent;
int ch = getch();
if (ch == KEY_MOUSE) {
if (getmouse(&mevent) == OK) {
event.x = mevent.x;
event.y = mevent.y;
// 解析鼠标事件类型
if (mevent.bstate & BUTTON1_CLICKED) {
event.type = MouseEvent::Type::CLICK;
event.button = 0;
return true;
} else if (mevent.bstate & BUTTON2_CLICKED) {
event.type = MouseEvent::Type::CLICK;
event.button = 1;
return true;
} else if (mevent.bstate & BUTTON3_CLICKED) {
event.type = MouseEvent::Type::CLICK;
event.button = 2;
return true;
}
#ifdef BUTTON4_PRESSED
else if (mevent.bstate & BUTTON4_PRESSED) {
event.type = MouseEvent::Type::SCROLL_UP;
return true;
}
#endif
#ifdef BUTTON5_PRESSED
else if (mevent.bstate & BUTTON5_PRESSED) {
event.type = MouseEvent::Type::SCROLL_DOWN;
return true;
}
#endif
}
}
return false;
}
// ==================== 终端能力 ====================
bool supports_true_color() const { return has_true_color_; }
bool supports_mouse() const { return has_mouse_; }
bool supports_unicode() const { return has_unicode_; }
bool supports_italic() const { return has_italic_; }
// ==================== 高级功能 ====================
void enable_mouse(bool enabled) {
if (enabled) {
// 启用所有鼠标事件
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, nullptr);
// 发送启用鼠标跟踪的ANSI序列
std::printf("\033[?1003h"); // 启用所有鼠标事件
std::fflush(stdout);
mouse_enabled_ = true;
} else {
mousemask(0, nullptr);
std::printf("\033[?1003l"); // 禁用鼠标跟踪
std::fflush(stdout);
mouse_enabled_ = false;
}
}
void use_alternate_screen(bool enabled) {
if (enabled) {
std::printf("\033[?1049h"); // 进入替代屏幕
} else {
std::printf("\033[?1049l"); // 退出替代屏幕
}
std::fflush(stdout);
}
private:
bool initialized_;
bool has_true_color_;
bool has_mouse_;
bool has_unicode_;
bool has_italic_;
int width_;
int height_;
bool mouse_enabled_;
};
// ==================== Terminal 公共接口 ====================
Terminal::Terminal() : pImpl(std::make_unique<Impl>()) {}
Terminal::~Terminal() = default;
bool Terminal::init() { return pImpl->init(); }
void Terminal::cleanup() { pImpl->cleanup(); }
void Terminal::get_size(int& width, int& height) {
pImpl->get_size(width, height);
}
void Terminal::clear() { pImpl->clear(); }
void Terminal::refresh() { pImpl->refresh(); }
void Terminal::set_foreground(uint32_t rgb) { pImpl->set_foreground(rgb); }
void Terminal::set_background(uint32_t rgb) { pImpl->set_background(rgb); }
void Terminal::reset_colors() { pImpl->reset_colors(); }
void Terminal::set_bold(bool enabled) { pImpl->set_bold(enabled); }
void Terminal::set_italic(bool enabled) { pImpl->set_italic(enabled); }
void Terminal::set_underline(bool enabled) { pImpl->set_underline(enabled); }
void Terminal::set_reverse(bool enabled) { pImpl->set_reverse(enabled); }
void Terminal::set_dim(bool enabled) { pImpl->set_dim(enabled); }
void Terminal::reset_attributes() { pImpl->reset_attributes(); }
void Terminal::move_cursor(int x, int y) { pImpl->move_cursor(x, y); }
void Terminal::hide_cursor() { pImpl->hide_cursor(); }
void Terminal::show_cursor() { pImpl->show_cursor(); }
void Terminal::print(const std::string& text) { pImpl->print(text); }
void Terminal::print_at(int x, int y, const std::string& text) {
pImpl->print_at(x, y, text);
}
int Terminal::get_key(int timeout_ms) { return pImpl->get_key(timeout_ms); }
bool Terminal::get_mouse_event(MouseEvent& event) {
return pImpl->get_mouse_event(event);
}
bool Terminal::supports_true_color() const { return pImpl->supports_true_color(); }
bool Terminal::supports_mouse() const { return pImpl->supports_mouse(); }
bool Terminal::supports_unicode() const { return pImpl->supports_unicode(); }
bool Terminal::supports_italic() const { return pImpl->supports_italic(); }
void Terminal::enable_mouse(bool enabled) { pImpl->enable_mouse(enabled); }
void Terminal::use_alternate_screen(bool enabled) {
pImpl->use_alternate_screen(enabled);
}
} // namespace tut

218
src/render/terminal.h Normal file
View file

@ -0,0 +1,218 @@
#pragma once
#include <string>
#include <cstdint>
#include <memory>
namespace tut {
// 鼠标事件类型
struct MouseEvent {
enum class Type {
CLICK,
SCROLL_UP,
SCROLL_DOWN,
MOVE,
DRAG
};
Type type;
int x;
int y;
int button; // 0=left, 1=middle, 2=right
};
/**
* Terminal -
*
* True Color (24-bit RGB)
* : iTerm2, Kitty, Alacritty等现代终端
*
* :
* - 使ANSI escape sequences而非ncurses color pairs (256)
* -
* - API
*/
class Terminal {
public:
Terminal();
~Terminal();
// ==================== 初始化与清理 ====================
/**
*
* -
* -
* -
* @return
*/
bool init();
/**
*
*/
void cleanup();
// ==================== 屏幕管理 ====================
/**
*
*/
void get_size(int& width, int& height);
/**
*
*/
void clear();
/**
*
*/
void refresh();
// ==================== True Color 支持 ====================
/**
* (24-bit RGB)
* @param rgb RGB颜色值: 0xRRGGBB
* : 0xE8C48C ()
*/
void set_foreground(uint32_t rgb);
/**
* (24-bit RGB)
* @param rgb RGB颜色值: 0xRRGGBB
*/
void set_background(uint32_t rgb);
/**
*
*/
void reset_colors();
// ==================== 文本属性 ====================
/**
*
*/
void set_bold(bool enabled);
/**
*
*/
void set_italic(bool enabled);
/**
* 线
*/
void set_underline(bool enabled);
/**
*
*/
void set_reverse(bool enabled);
/**
*
*/
void set_dim(bool enabled);
/**
*
*/
void reset_attributes();
// ==================== 光标控制 ====================
/**
*
* @param x (0-based)
* @param y (0-based)
*/
void move_cursor(int x, int y);
/**
*
*/
void hide_cursor();
/**
*
*/
void show_cursor();
// ==================== 文本输出 ====================
/**
*
*/
void print(const std::string& text);
/**
*
*/
void print_at(int x, int y, const std::string& text);
// ==================== 输入处理 ====================
/**
*
* @param timeout_ms -1
* @return -1
*/
int get_key(int timeout_ms = -1);
/**
*
* @param event
* @return
*/
bool get_mouse_event(MouseEvent& event);
// ==================== 终端能力检测 ====================
/**
* True Color (24-bit)
* : COLORTERM=truecolor COLORTERM=24bit
*/
bool supports_true_color() const;
/**
*
*/
bool supports_mouse() const;
/**
* Unicode
*/
bool supports_unicode() const;
/**
*
*/
bool supports_italic() const;
// ==================== 高级功能 ====================
/**
* /
*/
void enable_mouse(bool enabled);
/**
* /
* (退)
*/
void use_alternate_screen(bool enabled);
private:
class Impl;
std::unique_ptr<Impl> pImpl;
// 禁止拷贝
Terminal(const Terminal&) = delete;
Terminal& operator=(const Terminal&) = delete;
};
} // namespace tut

View file

@ -26,12 +26,71 @@ struct RenderedLine {
std::vector<InteractiveRange> interactive_ranges;
};
// Unicode装饰字符
namespace UnicodeChars {
// 框线字符 (Box Drawing)
constexpr const char* DBL_HORIZONTAL = "";
constexpr const char* DBL_VERTICAL = "";
constexpr const char* DBL_TOP_LEFT = "";
constexpr const char* DBL_TOP_RIGHT = "";
constexpr const char* DBL_BOTTOM_LEFT = "";
constexpr const char* DBL_BOTTOM_RIGHT = "";
constexpr const char* SGL_HORIZONTAL = "";
constexpr const char* SGL_VERTICAL = "";
constexpr const char* SGL_TOP_LEFT = "";
constexpr const char* SGL_TOP_RIGHT = "";
constexpr const char* SGL_BOTTOM_LEFT = "";
constexpr const char* SGL_BOTTOM_RIGHT = "";
constexpr const char* SGL_CROSS = "";
constexpr const char* SGL_T_DOWN = "";
constexpr const char* SGL_T_UP = "";
constexpr const char* SGL_T_RIGHT = "";
constexpr const char* SGL_T_LEFT = "";
constexpr const char* HEAVY_HORIZONTAL = "";
constexpr const char* HEAVY_VERTICAL = "";
// 列表符号
constexpr const char* BULLET = "";
constexpr const char* CIRCLE = "";
constexpr const char* SQUARE = "";
constexpr const char* TRIANGLE = "";
// 装饰符号
constexpr const char* SECTION = "§";
constexpr const char* PARAGRAPH = "";
constexpr const char* ARROW_RIGHT = "";
constexpr const char* ELLIPSIS = "";
}
struct RenderConfig {
int max_width = 80;
int margin_left = 0;
bool center_content = false; // 改为false全宽渲染
int paragraph_spacing = 1;
bool show_link_indicators = false; // Set to false to show inline links by default
// 布局设置
int max_width = 80; // 最大内容宽度
int margin_left = 0; // 左边距
bool center_content = false; // 内容居中
int paragraph_spacing = 1; // 段落间距
// 响应式宽度设置
bool responsive_width = true; // 启用响应式宽度
int min_width = 60; // 最小内容宽度
int max_content_width = 100; // 最大内容宽度
int small_screen_threshold = 80; // 小屏阈值
int large_screen_threshold = 120;// 大屏阈值
// 链接设置
bool show_link_indicators = false; // 不显示[N]编号
bool inline_links = true; // 内联链接(仅颜色)
// 视觉样式
bool use_unicode_boxes = true; // 使用Unicode框线
bool use_fancy_bullets = true; // 使用精美列表符号
bool show_decorative_lines = true; // 显示装饰线
// 标题样式
bool h1_use_double_border = true; // H1使用双线框
bool h2_use_single_border = true; // H2使用单线框
bool h3_use_underline = true; // H3使用下划线
};
// 渲染上下文

86
src/utils/unicode.cpp Normal file
View file

@ -0,0 +1,86 @@
#include "unicode.h"
namespace tut {
size_t Unicode::display_width(const std::string& text) {
size_t width = 0;
for (size_t i = 0; i < text.length(); ) {
unsigned char c = text[i];
if (c < 0x80) {
// ASCII
width += 1;
i += 1;
} else if ((c & 0xE0) == 0xC0) {
// 2-byte UTF-8 (e.g., Latin extended)
width += 1;
i += 2;
} else if ((c & 0xF0) == 0xE0) {
// 3-byte UTF-8 (CJK characters)
width += 2;
i += 3;
} else if ((c & 0xF8) == 0xF0) {
// 4-byte UTF-8 (emoji, rare symbols)
width += 2;
i += 4;
} else {
// Invalid UTF-8, skip
i += 1;
}
}
return width;
}
size_t Unicode::char_byte_length(const std::string& text, size_t pos) {
if (pos >= text.length()) return 0;
unsigned char c = text[pos];
if (c < 0x80) return 1;
if ((c & 0xE0) == 0xC0) return 2;
if ((c & 0xF0) == 0xE0) return 3;
if ((c & 0xF8) == 0xF0) return 4;
return 1; // Invalid, treat as single byte
}
size_t Unicode::char_count(const std::string& text) {
size_t count = 0;
for (size_t i = 0; i < text.length(); ) {
i += char_byte_length(text, i);
count++;
}
return count;
}
std::string Unicode::truncate_to_width(const std::string& text, size_t max_width) {
std::string result;
size_t current_width = 0;
for (size_t i = 0; i < text.length(); ) {
size_t byte_len = char_byte_length(text, i);
unsigned char c = text[i];
// Calculate width of this character
size_t char_width = 1;
if ((c & 0xF0) == 0xE0 || (c & 0xF8) == 0xF0) {
char_width = 2; // CJK or emoji
}
if (current_width + char_width > max_width) {
break;
}
result += text.substr(i, byte_len);
current_width += char_width;
i += byte_len;
}
return result;
}
std::string Unicode::pad_to_width(const std::string& text, size_t target_width, char pad_char) {
size_t current_width = display_width(text);
if (current_width >= target_width) return text;
return text + std::string(target_width - current_width, pad_char);
}
} // namespace tut

37
src/utils/unicode.h Normal file
View file

@ -0,0 +1,37 @@
#pragma once
#include <string>
#include <cstddef>
namespace tut {
class Unicode {
public:
/**
* CJKemoji
* ASCII=1, 2-byte=1, 3-byte(CJK)=2, 4-byte(emoji)=2
*/
static size_t display_width(const std::string& text);
/**
* UTF-8
*/
static size_t char_byte_length(const std::string& text, size_t pos);
/**
* UTF-8
*/
static size_t char_count(const std::string& text);
/**
*
*
*/
static std::string truncate_to_width(const std::string& text, size_t max_width);
/**
*
*/
static std::string pad_to_width(const std::string& text, size_t target_width, char pad_char = ' ');
};
} // namespace tut

269
tests/test_layout.cpp Normal file
View file

@ -0,0 +1,269 @@
/**
* test_layout.cpp - Layout引擎测试
*
*
* 1. DOM树构建
* 2.
* 3.
*/
#include "render/terminal.h"
#include "render/renderer.h"
#include "render/layout.h"
#include "render/colors.h"
#include "dom_tree.h"
#include <iostream>
#include <string>
#include <ncurses.h>
using namespace tut;
void test_image_placeholder() {
std::cout << "=== 图片占位符测试 ===\n";
std::string html = R"(
<!DOCTYPE html>
<html>
<head><title></title></head>
<body>
<h1></h1>
<p>:</p>
<img src="https://example.com/photo.png" alt="Example Photo" />
<p></p>
<img src="logo.jpg" />
<img alt="Only alt text" />
<img />
</body>
</html>
)";
DomTreeBuilder builder;
DocumentTree doc = builder.build(html, "test://");
LayoutEngine engine(80);
LayoutResult layout = engine.layout(doc);
std::cout << "图片测试 - 总块数: " << layout.blocks.size() << "\n";
std::cout << "图片测试 - 总行数: " << layout.total_lines << "\n";
// 检查渲染输出
int img_count = 0;
for (const auto& block : layout.blocks) {
if (block.type == ElementType::IMAGE) {
img_count++;
if (!block.lines.empty() && !block.lines[0].spans.empty()) {
std::cout << " 图片 " << img_count << ": " << block.lines[0].spans[0].text << "\n";
}
}
}
std::cout << "找到 " << img_count << " 个图片块\n\n";
}
void test_layout_basic() {
std::cout << "=== Layout 基础测试 ===\n";
// 测试HTML
std::string html = R"(
<!DOCTYPE html>
<html>
<head><title></title></head>
<body>
<h1>TUT 2.0 </h1>
<p></p>
<h2></h2>
<ul>
<li> 1</li>
<li> 2</li>
<li> 3</li>
</ul>
<h2></h2>
<p> <a href="https://example.com"></a>访</p>
<blockquote></blockquote>
<hr>
<p></p>
</body>
</html>
)";
// 构建DOM树
DomTreeBuilder builder;
DocumentTree doc = builder.build(html, "test://");
std::cout << "DOM树构建: OK\n";
std::cout << "标题: " << doc.title << "\n";
std::cout << "链接数: " << doc.links.size() << "\n";
// 布局计算
LayoutEngine engine(80);
LayoutResult layout = engine.layout(doc);
std::cout << "布局计算: OK\n";
std::cout << "布局块数: " << layout.blocks.size() << "\n";
std::cout << "总行数: " << layout.total_lines << "\n";
// 打印布局块信息
std::cout << "\n布局块详情:\n";
int block_num = 0;
for (const auto& block : layout.blocks) {
std::cout << " Block " << block_num++ << ": "
<< block.lines.size() << " lines, "
<< "margin_top=" << block.margin_top << ", "
<< "margin_bottom=" << block.margin_bottom << "\n";
}
std::cout << "\nLayout 基础测试完成!\n";
}
void demo_layout_render(Terminal& term) {
int w, h;
term.get_size(w, h);
// 创建测试HTML
std::string html = R"(
<!DOCTYPE html>
<html>
<head><title>TUT 2.0 </title></head>
<body>
<h1>TUT 2.0 - </h1>
<p> True Color Unicode </p>
<h2></h2>
<ul>
<li>True Color 24</li>
<li>Unicode CJK字符</li>
<li></li>
<li></li>
</ul>
<h2></h2>
<p>访 <a href="https://example.com">Example</a> <a href="https://github.com">GitHub</a> </p>
<h3></h3>
<blockquote>Unix哲学</blockquote>
<hr>
<p>使 j/k q 退</p>
</body>
</html>
)";
// 构建DOM树
DomTreeBuilder builder;
DocumentTree doc = builder.build(html, "demo://");
// 布局计算
LayoutEngine engine(w);
LayoutResult layout = engine.layout(doc);
// 创建帧缓冲区和渲染器
FrameBuffer fb(w, h - 2); // 留出状态栏空间
Renderer renderer(term);
DocumentRenderer doc_renderer(fb);
int scroll_offset = 0;
int max_scroll = std::max(0, layout.total_lines - (h - 2));
int active_link = -1;
int num_links = static_cast<int>(doc.links.size());
bool running = true;
while (running) {
// 清空缓冲区
fb.clear_with_color(colors::BG_PRIMARY);
// 渲染文档
RenderContext render_ctx;
render_ctx.active_link = active_link;
doc_renderer.render(layout, scroll_offset, render_ctx);
// 渲染状态栏
std::string status = layout.title + " | 行 " + std::to_string(scroll_offset + 1) +
"/" + std::to_string(layout.total_lines);
if (active_link >= 0 && active_link < num_links) {
status += " | 链接: " + doc.links[active_link].url;
}
// 截断过长的状态栏
if (Unicode::display_width(status) > static_cast<size_t>(w - 2)) {
status = status.substr(0, w - 5) + "...";
}
// 状态栏在最后一行
for (int x = 0; x < w; ++x) {
fb.set_cell(x, h - 2, Cell{" ", colors::STATUSBAR_FG, colors::STATUSBAR_BG, ATTR_NONE});
}
fb.set_text(1, h - 2, status, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
// 渲染到终端
renderer.render(fb);
// 处理输入
int key = term.get_key(100);
switch (key) {
case 'q':
case 'Q':
running = false;
break;
case 'j':
case KEY_DOWN:
if (scroll_offset < max_scroll) scroll_offset++;
break;
case 'k':
case KEY_UP:
if (scroll_offset > 0) scroll_offset--;
break;
case ' ':
case KEY_NPAGE:
scroll_offset = std::min(scroll_offset + (h - 3), max_scroll);
break;
case 'b':
case KEY_PPAGE:
scroll_offset = std::max(scroll_offset - (h - 3), 0);
break;
case 'g':
case KEY_HOME:
scroll_offset = 0;
break;
case 'G':
case KEY_END:
scroll_offset = max_scroll;
break;
case '\t': // Tab键切换链接
if (num_links > 0) {
active_link = (active_link + 1) % num_links;
}
break;
case KEY_BTAB: // Shift+Tab
if (num_links > 0) {
active_link = (active_link - 1 + num_links) % num_links;
}
break;
}
}
}
int main() {
// 先运行非终端测试
test_image_placeholder();
test_layout_basic();
std::cout << "\n按回车键进入交互演示 (或 Ctrl+C 退出)...\n";
std::cin.get();
// 交互演示
Terminal term;
if (!term.init()) {
std::cerr << "终端初始化失败!\n";
return 1;
}
term.use_alternate_screen(true);
term.hide_cursor();
demo_layout_render(term);
term.show_cursor();
term.use_alternate_screen(false);
term.cleanup();
std::cout << "Layout 测试完成!\n";
return 0;
}

156
tests/test_renderer.cpp Normal file
View file

@ -0,0 +1,156 @@
/**
* test_renderer.cpp - FrameBuffer Renderer
*
*
* 1. Unicode字符宽度计算
* 2. FrameBuffer操作
* 3.
*/
#include "render/terminal.h"
#include "render/renderer.h"
#include "render/colors.h"
#include "render/decorations.h"
#include "utils/unicode.h"
#include <iostream>
#include <thread>
#include <chrono>
using namespace tut;
void test_unicode() {
std::cout << "=== Unicode 测试 ===\n";
// 测试用例
struct TestCase {
std::string text;
size_t expected_width;
const char* description;
};
TestCase tests[] = {
{"Hello", 5, "ASCII"},
{"你好", 4, "中文(2字符,宽度4)"},
{"Hello世界", 9, "混合ASCII+中文"},
{"🎉", 2, "Emoji"},
{"café", 4, "带重音符号"},
};
bool all_passed = true;
for (const auto& tc : tests) {
size_t width = Unicode::display_width(tc.text);
bool pass = (width == tc.expected_width);
std::cout << (pass ? "[OK] " : "[FAIL] ")
<< tc.description << ": \"" << tc.text << "\" "
<< "width=" << width
<< " (expected " << tc.expected_width << ")\n";
if (!pass) all_passed = false;
}
std::cout << (all_passed ? "\n所有Unicode测试通过!\n" : "\n部分测试失败!\n");
}
void test_framebuffer() {
std::cout << "\n=== FrameBuffer 测试 ===\n";
FrameBuffer fb(80, 24);
std::cout << "创建 80x24 FrameBuffer: OK\n";
// 测试set_text
fb.set_text(0, 0, "Hello World", colors::FG_PRIMARY, colors::BG_PRIMARY);
std::cout << "set_text ASCII: OK\n";
fb.set_text(0, 1, "你好世界", colors::H1_FG, colors::BG_PRIMARY);
std::cout << "set_text 中文: OK\n";
// 验证单元格
const Cell& cell = fb.get_cell(0, 0);
if (cell.content == "H" && cell.fg == colors::FG_PRIMARY) {
std::cout << "get_cell 验证: OK\n";
} else {
std::cout << "get_cell 验证: FAIL\n";
}
std::cout << "FrameBuffer 测试完成!\n";
}
void demo_renderer(Terminal& term) {
int w, h;
term.get_size(w, h);
FrameBuffer fb(w, h);
Renderer renderer(term);
// 清屏并显示标题
fb.clear_with_color(colors::BG_PRIMARY);
// 标题
std::string title = "TUT 2.0 - Renderer Demo";
int title_x = (w - Unicode::display_width(title)) / 2;
fb.set_text(title_x, 1, title, colors::H1_FG, colors::BG_PRIMARY, ATTR_BOLD);
// 分隔线
std::string line = make_horizontal_line(w - 4, chars::SGL_HORIZONTAL);
fb.set_text(2, 2, line, colors::BORDER, colors::BG_PRIMARY);
// 颜色示例
fb.set_text(2, 4, "颜色示例:", colors::FG_PRIMARY, colors::BG_PRIMARY, ATTR_BOLD);
fb.set_text(4, 5, chars::BULLET + std::string(" H1标题色"), colors::H1_FG, colors::BG_PRIMARY);
fb.set_text(4, 6, chars::BULLET + std::string(" H2标题色"), colors::H2_FG, colors::BG_PRIMARY);
fb.set_text(4, 7, chars::BULLET + std::string(" H3标题色"), colors::H3_FG, colors::BG_PRIMARY);
fb.set_text(4, 8, chars::BULLET + std::string(" 链接色"), colors::LINK_FG, colors::BG_PRIMARY);
// 装饰字符示例
fb.set_text(2, 10, "装饰字符:", colors::FG_PRIMARY, colors::BG_PRIMARY, ATTR_BOLD);
fb.set_text(4, 11, std::string(chars::DBL_TOP_LEFT) + make_horizontal_line(20, chars::DBL_HORIZONTAL) + chars::DBL_TOP_RIGHT,
colors::BORDER, colors::BG_PRIMARY);
fb.set_text(4, 12, std::string(chars::DBL_VERTICAL) + " 双线边框示例 " + chars::DBL_VERTICAL,
colors::BORDER, colors::BG_PRIMARY);
fb.set_text(4, 13, std::string(chars::DBL_BOTTOM_LEFT) + make_horizontal_line(20, chars::DBL_HORIZONTAL) + chars::DBL_BOTTOM_RIGHT,
colors::BORDER, colors::BG_PRIMARY);
// Unicode宽度示例
fb.set_text(2, 15, "Unicode宽度:", colors::FG_PRIMARY, colors::BG_PRIMARY, ATTR_BOLD);
fb.set_text(4, 16, "ASCII: Hello (5)", colors::FG_SECONDARY, colors::BG_PRIMARY);
fb.set_text(4, 17, "中文: 你好世界 (8)", colors::FG_SECONDARY, colors::BG_PRIMARY);
// 提示
fb.set_text(2, h - 2, "按 'q' 退出", colors::FG_DIM, colors::BG_PRIMARY);
// 渲染
renderer.render(fb);
// 等待退出
while (true) {
int key = term.get_key(100);
if (key == 'q' || key == 'Q') break;
}
}
int main() {
// 先运行非终端测试
test_unicode();
test_framebuffer();
std::cout << "\n按回车键进入交互演示 (或 Ctrl+C 退出)...\n";
std::cin.get();
// 交互演示
Terminal term;
if (!term.init()) {
std::cerr << "终端初始化失败!\n";
return 1;
}
term.use_alternate_screen(true);
term.hide_cursor();
demo_renderer(term);
term.show_cursor();
term.use_alternate_screen(false);
term.cleanup();
std::cout << "Renderer 测试完成!\n";
return 0;
}

222
tests/test_terminal.cpp Normal file
View file

@ -0,0 +1,222 @@
/**
* test_terminal.cpp - Terminal类True Color功能测试
*
* :
* 1. True Color (24-bit RGB)
* 2. (线)
* 3. Unicode字符显示
* 4.
*/
#include "terminal.h"
#include <iostream>
#include <thread>
#include <chrono>
using namespace tut;
void test_true_color(Terminal& term) {
term.clear();
// 标题
term.move_cursor(0, 0);
term.set_bold(true);
term.set_foreground(0xE8C48C); // 暖金色
term.print("TUT 2.0 - True Color Test");
term.reset_attributes();
// 能力检测报告
int y = 2;
term.move_cursor(0, y++);
term.print("Terminal Capabilities:");
term.move_cursor(0, y++);
term.print(" True Color: ");
if (term.supports_true_color()) {
term.set_foreground(0x00FF00);
term.print("✓ Supported");
} else {
term.set_foreground(0xFF0000);
term.print("✗ Not Supported");
}
term.reset_colors();
term.move_cursor(0, y++);
term.print(" Mouse: ");
if (term.supports_mouse()) {
term.set_foreground(0x00FF00);
term.print("✓ Supported");
} else {
term.set_foreground(0xFF0000);
term.print("✗ Not Supported");
}
term.reset_colors();
term.move_cursor(0, y++);
term.print(" Unicode: ");
if (term.supports_unicode()) {
term.set_foreground(0x00FF00);
term.print("✓ Supported");
} else {
term.set_foreground(0xFF0000);
term.print("✗ Not Supported");
}
term.reset_colors();
term.move_cursor(0, y++);
term.print(" Italic: ");
if (term.supports_italic()) {
term.set_foreground(0x00FF00);
term.print("✓ Supported");
} else {
term.set_foreground(0xFF0000);
term.print("✗ Not Supported");
}
term.reset_colors();
y++;
// 报纸风格颜色主题测试
term.move_cursor(0, y++);
term.set_bold(true);
term.print("Newspaper Color Theme:");
term.reset_attributes();
y++;
// H1 颜色
term.move_cursor(0, y++);
term.set_bold(true);
term.set_foreground(0xE8C48C); // 暖金色
term.print(" H1 Heading - Warm Gold (0xE8C48C)");
term.reset_attributes();
// H2 颜色
term.move_cursor(0, y++);
term.set_bold(true);
term.set_foreground(0xD4B078); // 较暗金色
term.print(" H2 Heading - Dark Gold (0xD4B078)");
term.reset_attributes();
// H3 颜色
term.move_cursor(0, y++);
term.set_bold(true);
term.set_foreground(0xC09C64); // 青铜色
term.print(" H3 Heading - Bronze (0xC09C64)");
term.reset_attributes();
y++;
// 链接颜色
term.move_cursor(0, y++);
term.set_foreground(0x87AFAF); // 青色
term.set_underline(true);
term.print(" Link - Teal (0x87AFAF)");
term.reset_attributes();
// 悬停链接
term.move_cursor(0, y++);
term.set_foreground(0xA7CFCF); // 浅青色
term.set_underline(true);
term.print(" Link Hover - Light Teal (0xA7CFCF)");
term.reset_attributes();
y++;
// 正文颜色
term.move_cursor(0, y++);
term.set_foreground(0xD0D0D0); // 浅灰
term.print(" Body Text - Light Gray (0xD0D0D0)");
term.reset_colors();
// 次要文本
term.move_cursor(0, y++);
term.set_foreground(0x909090); // 中灰
term.print(" Secondary Text - Medium Gray (0x909090)");
term.reset_colors();
y++;
// Unicode装饰测试
term.move_cursor(0, y++);
term.set_bold(true);
term.print("Unicode Box Drawing:");
term.reset_attributes();
y++;
// 双线框
term.move_cursor(0, y++);
term.set_foreground(0x404040);
term.print(" ╔═══════════════════════════════════╗");
term.move_cursor(0, y++);
term.print(" ║ Double Border for H1 Headings ║");
term.move_cursor(0, y++);
term.print(" ╚═══════════════════════════════════╝");
term.reset_colors();
y++;
// 单线框
term.move_cursor(0, y++);
term.set_foreground(0x404040);
term.print(" ┌───────────────────────────────────┐");
term.move_cursor(0, y++);
term.print(" │ Single Border for Code Blocks │");
term.move_cursor(0, y++);
term.print(" └───────────────────────────────────┘");
term.reset_colors();
y++;
// 引用块
term.move_cursor(0, y++);
term.set_foreground(0x6A8F8F);
term.print(" ┃ Blockquote with heavy vertical bar");
term.reset_colors();
y++;
// 列表符号
term.move_cursor(0, y++);
term.print(" • Bullet point (level 1)");
term.move_cursor(0, y++);
term.print(" ◦ Circle (level 2)");
term.move_cursor(0, y++);
term.print(" ▪ Square (level 3)");
y += 2;
// 提示
term.move_cursor(0, y++);
term.set_dim(true);
term.print("Press any key to exit...");
term.reset_attributes();
term.refresh();
}
int main() {
Terminal term;
if (!term.init()) {
std::cerr << "Failed to initialize terminal" << std::endl;
return 1;
}
try {
test_true_color(term);
// 等待按键
term.get_key(-1);
term.cleanup();
} catch (const std::exception& e) {
term.cleanup();
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}