diff --git a/CMakeLists.txt b/CMakeLists.txt index dd96c7b..360c9ed 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 - $<$:-O2> - $<$:-g -O0> -) - -# Installation -install(TARGETS tut DESTINATION bin) - - diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md new file mode 100644 index 0000000..6b2efe6 --- /dev/null +++ b/NEXT_STEPS.md @@ -0,0 +1,140 @@ +# TUT 2.0 - 下次继续从这里开始 + +## 当前位置 +- **阶段**: Phase 4 - 图片支持 +- **进度**: 基础功能完成 (占位符显示) +- **最后完成**: 图片占位符渲染 + 二进制数据下载支持 + +## Phase 4 已完成功能 + +### 图片占位符 (已完成) +- `` 标签解析和渲染 +- 显示 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 diff --git a/src/browser_v2.cpp b/src/browser_v2.cpp new file mode 100644 index 0000000..2527693 --- /dev/null +++ b/src/browser_v2.cpp @@ -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 +#include +#include +#include +#include +#include +#include + +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(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; + std::unique_ptr renderer; + std::unique_ptr layout_engine; + + // 文档状态 + DocumentTree current_tree; + LayoutResult current_layout; + std::string current_url; + std::vector 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 marks; + + // 搜索相关 + SearchContext search_ctx; + + // 页面缓存 + std::map 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(screen_width, screen_height); + renderer = std::make_unique(terminal); + layout_engine = std::make_unique(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(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(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(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(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(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(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(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(pos); + match.length = static_cast(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(search_ctx.matches.size()); + } + + // 跳转到指定匹配 + void scroll_to_match(int idx) { + if (idx < 0 || idx >= static_cast(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(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"( + + +TUT 2.0 Help + +

TUT 2.0 - Terminal Browser

+ +

Navigation

+
    +
  • j/k - Scroll down/up
  • +
  • Ctrl+d/Ctrl+u - Page down/up
  • +
  • gg - Go to top
  • +
  • G - Go to bottom
  • +
+ +

Links

+
    +
  • Tab - Next link
  • +
  • Shift+Tab - Previous link
  • +
  • Enter - Follow link
  • +
+ +

History

+
    +
  • h - Go back
  • +
  • l - Go forward
  • +
+ +

Search

+
    +
  • / - Search forward
  • +
  • n - Next match
  • +
  • N - Previous match
  • +
+ +

Commands

+
    +
  • :o URL - Open URL
  • +
  • :q - Quit
  • +
  • ? - Show this help
  • +
+ +

Forms

+
    +
  • Tab - Navigate links and form fields
  • +
  • Enter - Activate link or submit form
  • +
+ +
+

TUT 2.0 - A modern terminal browser with True Color support

+ + +)"; + 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()) { + 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; +} diff --git a/src/browser_v2.h b/src/browser_v2.h new file mode 100644 index 0000000..ecea74b --- /dev/null +++ b/src/browser_v2.h @@ -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 +#include +#include + +/** + * 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 pImpl; +}; diff --git a/src/dom_tree.cpp b/src/dom_tree.cpp index ab33826..7374c18 100644 --- a/src/dom_tree.cpp +++ b/src/dom_tree.cpp @@ -265,8 +265,23 @@ std::unique_ptr 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 (...) {} + } } diff --git a/src/dom_tree.h b/src/dom_tree.h index 2fe4f4d..21dcd4c 100644 --- a/src/dom_tree.h +++ b/src/dom_tree.h @@ -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; diff --git a/src/http_client.cpp b/src/http_client.cpp index 0e0eff8..98be802 100644 --- a/src/http_client.cpp +++ b/src/http_client.cpp @@ -2,13 +2,21 @@ #include #include -// 回调函数用于接收数据 +// 回调函数用于接收文本数据 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(contents), total_size); return total_size; } +// 回调函数用于接收二进制数据 +static size_t binary_write_callback(void* contents, size_t size, size_t nmemb, std::vector* userp) { + size_t total_size = size * nmemb; + uint8_t* data = static_cast(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 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(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; diff --git a/src/http_client.h b/src/http_client.h index e840843..842dfff 100644 --- a/src/http_client.h +++ b/src/http_client.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include 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 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); diff --git a/src/main_v2.cpp b/src/main_v2.cpp new file mode 100644 index 0000000..f370033 --- /dev/null +++ b/src/main_v2.cpp @@ -0,0 +1,50 @@ +#include "browser_v2.h" +#include +#include + +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; +} diff --git a/src/render/colors.h b/src/render/colors.h new file mode 100644 index 0000000..8bac59b --- /dev/null +++ b/src/render/colors.h @@ -0,0 +1,116 @@ +#pragma once + +#include + +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(r) << 16) | + (static_cast(g) << 8) | + static_cast(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(get_red(c1) * (1 - t) + get_red(c2) * t); + uint8_t g = static_cast(get_green(c1) * (1 - t) + get_green(c2) * t); + uint8_t b = static_cast(get_blue(c1) * (1 - t) + get_blue(c2) * t); + return rgb(r, g, b); +} + +} // namespace tut diff --git a/src/render/decorations.h b/src/render/decorations.h new file mode 100644 index 0000000..4afa9ad --- /dev/null +++ b/src/render/decorations.h @@ -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 diff --git a/src/render/image.cpp b/src/render/image.cpp new file mode 100644 index 0000000..5d5b600 --- /dev/null +++ b/src/render/image.cpp @@ -0,0 +1,265 @@ +#include "image.h" +#include +#include +#include +#include + +// 尝试加载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& 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(data.width) / data.height; + int target_width = max_width; + int target_height = static_cast(target_width / aspect / 2.0f); + + if (target_height > max_height) { + target_height = max_height; + target_width = static_cast(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& buffer) { + ImageData data; + +#if HAS_STB_IMAGE + int width, height, channels; + unsigned char* pixels = stbi_load_from_memory( + buffer.data(), + static_cast(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(r) << 16) | + (static_cast(g) << 8) | + static_cast(b); +} + +int ImageRenderer::pixel_brightness(uint8_t r, uint8_t g, uint8_t b) { + // 使用加权平均计算亮度 (ITU-R BT.601) + return static_cast(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(src.width) / new_width; + float y_ratio = static_cast(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(x * x_ratio); + int src_y = static_cast(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 diff --git a/src/render/image.h b/src/render/image.h new file mode 100644 index 0000000..470aa14 --- /dev/null +++ b/src/render/image.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include + +namespace tut { + +/** + * ImageData - 解码后的图片数据 + */ +struct ImageData { + std::vector 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 lines; // 每行的ASCII字符 + std::vector> 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& 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 diff --git a/src/render/layout.cpp b/src/render/layout.cpp new file mode 100644 index 0000000..ca84776 --- /dev/null +++ b/src/render/layout.cpp @@ -0,0 +1,714 @@ +#include "layout.h" +#include "decorations.h" +#include "image.h" +#include +#include + +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(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(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& 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& 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 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& 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(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& 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& 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& 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 LayoutEngine::wrap_text(const std::vector& spans, int available_width, int indent) { + std::vector 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(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(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(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(span.display_width()); + } + } +} + +} // namespace tut diff --git a/src/render/layout.h b/src/render/layout.h new file mode 100644 index 0000000..b51029c --- /dev/null +++ b/src/render/layout.h @@ -0,0 +1,197 @@ +#pragma once + +#include "renderer.h" +#include "colors.h" +#include "../dom_tree.h" +#include "../utils/unicode.h" +#include +#include +#include + +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 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 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 blocks; + int total_lines = 0; // 总行数(包括边距) + std::string title; + std::string url; + + // 链接位置映射 (link_index -> LinkPosition) + std::vector link_positions; + + // 表单字段位置映射 (field_index -> line_number) + std::vector 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& blocks); + void layout_block_element(const DomNode* node, Context& ctx, std::vector& blocks); + void layout_form_element(const DomNode* node, Context& ctx, std::vector& blocks); + void layout_image_element(const DomNode* node, Context& ctx, std::vector& blocks); + void layout_text(const DomNode* node, Context& ctx, std::vector& spans); + + // 收集内联内容 + void collect_inline_content(const DomNode* node, Context& ctx, std::vector& spans); + + // 文本换行 + std::vector wrap_text(const std::vector& 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 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 diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp new file mode 100644 index 0000000..0313dfa --- /dev/null +++ b/src/render/renderer.cpp @@ -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(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 diff --git a/src/render/renderer.h b/src/render/renderer.h new file mode 100644 index 0000000..906d438 --- /dev/null +++ b/src/render/renderer.h @@ -0,0 +1,103 @@ +#pragma once + +#include "terminal.h" +#include +#include +#include + +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> 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 diff --git a/src/render/terminal.cpp b/src/render/terminal.cpp new file mode 100644 index 0000000..74cb849 --- /dev/null +++ b/src/render/terminal.cpp @@ -0,0 +1,410 @@ +#include "terminal.h" +#include +#include +#include +#include +#include + +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()) {} +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 diff --git a/src/render/terminal.h b/src/render/terminal.h new file mode 100644 index 0000000..42bce9c --- /dev/null +++ b/src/render/terminal.h @@ -0,0 +1,218 @@ +#pragma once + +#include +#include +#include + +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 pImpl; + + // 禁止拷贝 + Terminal(const Terminal&) = delete; + Terminal& operator=(const Terminal&) = delete; +}; + +} // namespace tut diff --git a/src/text_renderer.h b/src/text_renderer.h index dd90c87..7050c15 100644 --- a/src/text_renderer.h +++ b/src/text_renderer.h @@ -26,12 +26,71 @@ struct RenderedLine { std::vector 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使用下划线 }; // 渲染上下文 diff --git a/src/utils/unicode.cpp b/src/utils/unicode.cpp new file mode 100644 index 0000000..3c64d8c --- /dev/null +++ b/src/utils/unicode.cpp @@ -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 diff --git a/src/utils/unicode.h b/src/utils/unicode.h new file mode 100644 index 0000000..d7a21a0 --- /dev/null +++ b/src/utils/unicode.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include + +namespace tut { + +class Unicode { +public: + /** + * 计算字符串的显示宽度(考虑CJK、emoji) + * 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 diff --git a/tests/test_layout.cpp b/tests/test_layout.cpp new file mode 100644 index 0000000..28a0cd0 --- /dev/null +++ b/tests/test_layout.cpp @@ -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 +#include +#include + +using namespace tut; + +void test_image_placeholder() { + std::cout << "=== 图片占位符测试 ===\n"; + + std::string html = R"( + + +图片测试 + +

图片测试页面

+

下面是一些图片:

+ Example Photo +

中间文本

+ + Only alt text + + + +)"; + + 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"( + + +测试页面 + +

TUT 2.0 布局引擎测试

+

这是一个段落,用于测试文本换行功能。当文本超过视口宽度时,应该自动换行到下一行。

+

列表测试

+
    +
  • 无序列表项目 1
  • +
  • 无序列表项目 2
  • +
  • 无序列表项目 3
  • +
+

链接测试

+

这是一个 链接示例,点击可以访问。

+
这是一段引用文本,应该带有左边框标记。
+
+

页面结束。

+ + +)"; + + // 构建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"( + + +TUT 2.0 布局演示 + +

TUT 2.0 - 终端浏览器

+ +

这是一个现代化的终端浏览器,支持 True Color 渲染、Unicode 字符以及差分渲染优化。

+ +

主要特性

+
    +
  • True Color 24位色彩支持
  • +
  • Unicode 字符正确显示(包括CJK字符)
  • +
  • 差分渲染提升性能
  • +
  • 温暖护眼的配色方案
  • +
+ +

链接示例

+

访问 ExampleGitHub 了解更多信息。

+ +

引用块

+
Unix哲学:做一件事,把它做好。
+ +
+ +

使用 j/k 滚动,q 退出。

+ + +)"; + + // 构建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(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(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; +} diff --git a/tests/test_renderer.cpp b/tests/test_renderer.cpp new file mode 100644 index 0000000..2f46f88 --- /dev/null +++ b/tests/test_renderer.cpp @@ -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 +#include +#include + +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; +} diff --git a/tests/test_terminal.cpp b/tests/test_terminal.cpp new file mode 100644 index 0000000..9b0068b --- /dev/null +++ b/tests/test_terminal.cpp @@ -0,0 +1,222 @@ +/** + * test_terminal.cpp - Terminal类True Color功能测试 + * + * 测试内容: + * 1. True Color (24-bit RGB) 支持 + * 2. 文本属性 (粗体、斜体、下划线) + * 3. Unicode字符显示 + * 4. 终端能力检测 + */ + +#include "terminal.h" +#include +#include +#include + +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; +}