mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 09:04:04 +00:00
Compare commits
11 commits
97a798f122
...
7ac0fc1c91
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ac0fc1c91 | |||
| 8d56a7b67b | |||
| 3f7b627da5 | |||
| 2878b42d36 | |||
| a469f79a1e | |||
| e5276e0b4c | |||
| 18859eef47 | |||
| 584660a518 | |||
| 18f7804145 | |||
| a4c95a6527 | |||
| c6b1a9ac41 |
30 changed files with 10501 additions and 2157 deletions
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -26,13 +26,13 @@ jobs:
|
|||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
brew update
|
||||
brew install cmake ncurses curl
|
||||
brew install cmake ncurses curl gumbo-parser
|
||||
|
||||
- name: Install dependencies (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake libncursesw5-dev libcurl4-openssl-dev
|
||||
sudo apt-get install -y cmake libncursesw5-dev libcurl4-openssl-dev libgumbo-dev
|
||||
|
||||
- name: Configure CMake
|
||||
run: |
|
||||
|
|
@ -47,7 +47,7 @@ jobs:
|
|||
|
||||
- name: Rename binary with platform suffix
|
||||
run: |
|
||||
mv build/tut build/tut-${{ matrix.name }}
|
||||
mv build/tut2 build/tut-${{ matrix.name }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
|
|||
103
CMakeLists.txt
103
CMakeLists.txt
|
|
@ -1,5 +1,5 @@
|
|||
cmake_minimum_required(VERSION 3.15)
|
||||
project(TUT_v2 VERSION 2.0.0 LANGUAGES CXX)
|
||||
project(TUT VERSION 2.0.0 LANGUAGES CXX)
|
||||
|
||||
# C++17标准
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
|
|
@ -23,20 +23,44 @@ pkg_check_modules(GUMBO REQUIRED gumbo)
|
|||
# 包含目录
|
||||
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}
|
||||
)
|
||||
|
||||
# ==================== Terminal 测试程序 ====================
|
||||
# ==================== TUT 主程序 ====================
|
||||
|
||||
add_executable(tut
|
||||
src/main.cpp
|
||||
src/browser.cpp
|
||||
src/http_client.cpp
|
||||
src/input_handler.cpp
|
||||
src/bookmark.cpp
|
||||
src/history.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(tut PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(tut
|
||||
${CURSES_LIBRARIES}
|
||||
CURL::libcurl
|
||||
${GUMBO_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== 测试程序 ====================
|
||||
|
||||
# Terminal 测试
|
||||
add_executable(test_terminal
|
||||
src/render/terminal.cpp
|
||||
tests/test_terminal.cpp
|
||||
|
|
@ -46,8 +70,7 @@ target_link_libraries(test_terminal
|
|||
${CURSES_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== Renderer 测试程序 ====================
|
||||
|
||||
# Renderer 测试
|
||||
add_executable(test_renderer
|
||||
src/render/terminal.cpp
|
||||
src/render/renderer.cpp
|
||||
|
|
@ -59,8 +82,7 @@ target_link_libraries(test_renderer
|
|||
${CURSES_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== Layout 测试程序 ====================
|
||||
|
||||
# Layout 测试
|
||||
add_executable(test_layout
|
||||
src/render/terminal.cpp
|
||||
src/render/renderer.cpp
|
||||
|
|
@ -81,50 +103,39 @@ target_link_libraries(test_layout
|
|||
${GUMBO_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== TUT 2.0 主程序 ====================
|
||||
|
||||
add_executable(tut2
|
||||
src/main_v2.cpp
|
||||
src/browser_v2.cpp
|
||||
# HTTP 异步测试
|
||||
add_executable(test_http_async
|
||||
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
|
||||
tests/test_http_async.cpp
|
||||
)
|
||||
|
||||
target_link_directories(tut2 PRIVATE
|
||||
target_link_libraries(test_http_async
|
||||
CURL::libcurl
|
||||
)
|
||||
|
||||
# HTML 解析测试
|
||||
add_executable(test_html_parse
|
||||
src/html_parser.cpp
|
||||
src/dom_tree.cpp
|
||||
tests/test_html_parse.cpp
|
||||
)
|
||||
|
||||
target_link_directories(test_html_parse PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(tut2
|
||||
${CURSES_LIBRARIES}
|
||||
CURL::libcurl
|
||||
target_link_libraries(test_html_parse
|
||||
${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
|
||||
# 书签测试
|
||||
add_executable(test_bookmark
|
||||
src/bookmark.cpp
|
||||
tests/test_bookmark.cpp
|
||||
)
|
||||
|
||||
target_link_directories(tut PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(tut
|
||||
${CURSES_LIBRARIES}
|
||||
CURL::libcurl
|
||||
${GUMBO_LIBRARIES}
|
||||
# 历史记录测试
|
||||
add_executable(test_history
|
||||
src/history.cpp
|
||||
tests/test_history.cpp
|
||||
)
|
||||
|
|
|
|||
130
NEXT_STEPS.md
130
NEXT_STEPS.md
|
|
@ -1,40 +1,53 @@
|
|||
# TUT 2.0 - 下次继续从这里开始
|
||||
|
||||
## 当前位置
|
||||
- **阶段**: Phase 4 - 图片支持 (基础完成)
|
||||
- **进度**: 占位符显示已完成,ASCII Art 渲染框架就绪
|
||||
- **最后提交**: `d80d0a1 feat: Implement TUT 2.0 with new rendering architecture`
|
||||
- **待推送**: 本地有 3 个提交未推送到 origin/main
|
||||
- **阶段**: Phase 7 - 历史记录持久化 (已完成!)
|
||||
- **进度**: 历史记录自动保存,支持 :history 命令查看
|
||||
- **最后提交**: `feat: Add persistent browsing history`
|
||||
|
||||
## 立即可做的事
|
||||
|
||||
### 1. 推送代码到远程
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
### 1. 使用书签功能
|
||||
- **B** - 添加当前页面到书签
|
||||
- **D** - 从书签中移除当前页面
|
||||
- **:bookmarks** 或 **:bm** - 查看书签列表
|
||||
|
||||
### 2. 启用完整图片支持 (PNG/JPEG)
|
||||
```bash
|
||||
# 下载 stb_image.h
|
||||
curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \
|
||||
-o src/utils/stb_image.h
|
||||
书签存储在 `~/.config/tut/bookmarks.json`
|
||||
|
||||
# 重新编译
|
||||
cmake --build build_v2
|
||||
### 2. 查看历史记录
|
||||
- **:history** 或 **:hist** - 查看浏览历史
|
||||
|
||||
# 编译后 ImageRenderer::load_from_memory() 将自动支持 PNG/JPEG/GIF/BMP
|
||||
```
|
||||
|
||||
### 3. 在浏览器中集成图片渲染
|
||||
需要在 `browser_v2.cpp` 中:
|
||||
1. 收集页面中的所有 `<img>` 标签
|
||||
2. 使用 `HttpClient::fetch_binary()` 下载图片
|
||||
3. 调用 `ImageRenderer::load_from_memory()` 解码
|
||||
4. 调用 `ImageRenderer::render()` 生成 ASCII Art
|
||||
5. 将结果插入到布局中
|
||||
历史记录存储在 `~/.config/tut/history.json`
|
||||
|
||||
## 已完成的功能清单
|
||||
|
||||
### Phase 7 - 历史记录持久化
|
||||
- [x] HistoryEntry 数据结构 (URL, 标题, 访问时间)
|
||||
- [x] JSON 持久化存储 (~/.config/tut/history.json)
|
||||
- [x] 自动记录访问历史
|
||||
- [x] 重复访问更新时间
|
||||
- [x] 最大 1000 条记录限制
|
||||
- [x] :history 命令查看历史页面
|
||||
- [x] 历史链接可点击跳转
|
||||
|
||||
### Phase 6 - 异步HTTP
|
||||
- [x] libcurl multi接口实现非阻塞请求
|
||||
- [x] AsyncState状态管理 (IDLE/LOADING/COMPLETE/FAILED/CANCELLED)
|
||||
- [x] start_async_fetch() 启动异步请求
|
||||
- [x] poll_async() 非阻塞轮询
|
||||
- [x] cancel_async() 取消请求
|
||||
- [x] 加载动画 (旋转spinner: ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏)
|
||||
- [x] Esc键取消加载
|
||||
- [x] 主循环50ms轮询集成
|
||||
|
||||
### Phase 5 - 书签管理
|
||||
- [x] 书签数据结构 (URL, 标题, 添加时间)
|
||||
- [x] JSON 持久化存储 (~/.config/tut/bookmarks.json)
|
||||
- [x] 添加书签 (B 键)
|
||||
- [x] 删除书签 (D 键)
|
||||
- [x] 书签列表页面 (:bookmarks 命令)
|
||||
- [x] 书签链接可点击跳转
|
||||
|
||||
### Phase 4 - 图片支持
|
||||
- [x] `<img>` 标签解析 (src, alt, width, height)
|
||||
- [x] 图片占位符显示 `[alt text]` 或 `[Image: filename]`
|
||||
|
|
@ -42,8 +55,9 @@ cmake --build build_v2
|
|||
- [x] `HttpClient::fetch_binary()` 方法
|
||||
- [x] `ImageRenderer` 类框架
|
||||
- [x] PPM 格式内置解码
|
||||
- [ ] stb_image.h 集成 (需手动下载)
|
||||
- [ ] 浏览器中的图片下载和渲染
|
||||
- [x] stb_image.h 集成 (PNG/JPEG/GIF/BMP 支持)
|
||||
- [x] 浏览器中的图片下载和渲染
|
||||
- [x] ASCII Art 彩色渲染 (True Color)
|
||||
|
||||
### Phase 3 - 性能优化
|
||||
- [x] LRU 页面缓存 (20页, 5分钟过期)
|
||||
|
|
@ -72,12 +86,14 @@ cmake --build build_v2
|
|||
|
||||
```
|
||||
src/
|
||||
├── browser_v2.cpp/h # 新架构浏览器 (pImpl模式)
|
||||
├── main_v2.cpp # tut2 入口点
|
||||
├── http_client.cpp/h # HTTP 客户端 (支持二进制)
|
||||
├── browser.cpp/h # 主浏览器 (pImpl模式)
|
||||
├── main.cpp # 程序入口点
|
||||
├── http_client.cpp/h # HTTP 客户端 (支持二进制和异步)
|
||||
├── dom_tree.cpp/h # DOM 树
|
||||
├── html_parser.cpp/h # HTML 解析
|
||||
├── input_handler.cpp/h # 输入处理
|
||||
├── bookmark.cpp/h # 书签管理
|
||||
├── history.cpp/h # 历史记录管理
|
||||
├── render/
|
||||
│ ├── terminal.cpp/h # 终端抽象 (ncurses)
|
||||
│ ├── renderer.cpp/h # FrameBuffer + 差分渲染
|
||||
|
|
@ -87,29 +103,36 @@ src/
|
|||
│ └── decorations.h # Unicode 装饰字符
|
||||
└── utils/
|
||||
├── unicode.cpp/h # Unicode 处理
|
||||
└── stb_image.h # [需下载] 图片解码库
|
||||
└── stb_image.h # 图片解码库
|
||||
|
||||
tests/
|
||||
├── test_terminal.cpp # Terminal 测试
|
||||
├── test_renderer.cpp # Renderer 测试
|
||||
└── test_layout.cpp # Layout + 图片占位符测试
|
||||
├── test_layout.cpp # Layout + 图片占位符测试
|
||||
├── test_http_async.cpp # HTTP 异步测试
|
||||
├── test_html_parse.cpp # HTML 解析测试
|
||||
├── test_bookmark.cpp # 书签测试
|
||||
└── test_history.cpp # 历史记录测试
|
||||
```
|
||||
|
||||
## 构建与运行
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
cmake -B build_v2 -S . -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build_v2
|
||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build
|
||||
|
||||
# 运行
|
||||
./build_v2/tut2 # 显示帮助
|
||||
./build_v2/tut2 https://example.com # 打开网页
|
||||
./build/tut # 显示帮助
|
||||
./build/tut https://example.com # 打开网页
|
||||
|
||||
# 测试
|
||||
./build_v2/test_terminal # 终端测试
|
||||
./build_v2/test_renderer # 渲染测试
|
||||
./build_v2/test_layout # 布局+图片测试 (按回车进入交互模式)
|
||||
./build/test_terminal # 终端测试
|
||||
./build/test_renderer # 渲染测试
|
||||
./build/test_layout # 布局+图片测试
|
||||
./build/test_http_async # HTTP异步测试
|
||||
./build/test_html_parse # HTML解析测试
|
||||
./build/test_bookmark # 书签测试
|
||||
```
|
||||
|
||||
## 快捷键
|
||||
|
|
@ -125,20 +148,39 @@ cmake --build build_v2
|
|||
| / | 搜索 |
|
||||
| n/N | 下一个/上一个匹配 |
|
||||
| r | 刷新 (跳过缓存) |
|
||||
| B | 添加书签 |
|
||||
| D | 删除书签 |
|
||||
| :o URL | 打开URL |
|
||||
| :bookmarks | 查看书签 |
|
||||
| :history | 查看历史 |
|
||||
| :q | 退出 |
|
||||
| ? | 帮助 |
|
||||
| Esc | 取消加载 |
|
||||
|
||||
## 下一步功能优先级
|
||||
|
||||
1. **完成图片 ASCII Art 渲染** - 下载 stb_image.h 并集成到浏览器
|
||||
2. **书签管理** - 添加/删除书签,书签列表页面,持久化存储
|
||||
3. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求
|
||||
4. **更多表单交互** - 文本输入编辑,下拉选择
|
||||
1. **更多表单交互** - 文本输入编辑,下拉选择
|
||||
2. **图片缓存** - 避免重复下载相同图片
|
||||
3. **异步图片加载** - 图片也使用异步加载
|
||||
4. **Cookie 支持** - 保存和发送 Cookie
|
||||
|
||||
## 恢复对话时说
|
||||
|
||||
> "继续TUT 2.0开发"
|
||||
|
||||
## Git 信息
|
||||
|
||||
- **当前标签**: `v2.0.0-alpha`
|
||||
- **远程仓库**: https://github.com/m1ngsama/TUT
|
||||
|
||||
```bash
|
||||
# 恢复开发
|
||||
git clone https://github.com/m1ngsama/TUT.git
|
||||
cd TUT
|
||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build
|
||||
./build/tut
|
||||
```
|
||||
|
||||
---
|
||||
更新时间: 2025-12-26 15:00
|
||||
更新时间: 2025-12-27
|
||||
|
|
|
|||
248
src/bookmark.cpp
Normal file
248
src/bookmark.cpp
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
#include "bookmark.h"
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <sys/stat.h>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace tut {
|
||||
|
||||
BookmarkManager::BookmarkManager() {
|
||||
load();
|
||||
}
|
||||
|
||||
BookmarkManager::~BookmarkManager() {
|
||||
save();
|
||||
}
|
||||
|
||||
std::string BookmarkManager::get_config_dir() {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home) {
|
||||
home = "/tmp";
|
||||
}
|
||||
return std::string(home) + "/.config/tut";
|
||||
}
|
||||
|
||||
std::string BookmarkManager::get_bookmarks_path() {
|
||||
return get_config_dir() + "/bookmarks.json";
|
||||
}
|
||||
|
||||
bool BookmarkManager::ensure_config_dir() {
|
||||
std::string dir = get_config_dir();
|
||||
|
||||
// 检查目录是否存在
|
||||
struct stat st;
|
||||
if (stat(dir.c_str(), &st) == 0) {
|
||||
return S_ISDIR(st.st_mode);
|
||||
}
|
||||
|
||||
// 创建 ~/.config 目录
|
||||
std::string config_dir = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") + "/.config";
|
||||
mkdir(config_dir.c_str(), 0755);
|
||||
|
||||
// 创建 ~/.config/tut 目录
|
||||
return mkdir(dir.c_str(), 0755) == 0 || errno == EEXIST;
|
||||
}
|
||||
|
||||
// 简单的 JSON 转义
|
||||
static std::string json_escape(const std::string& s) {
|
||||
std::string result;
|
||||
result.reserve(s.size() + 10);
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': result += "\\\""; break;
|
||||
case '\\': result += "\\\\"; break;
|
||||
case '\n': result += "\\n"; break;
|
||||
case '\r': result += "\\r"; break;
|
||||
case '\t': result += "\\t"; break;
|
||||
default: result += c; break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 简单的 JSON 反转义
|
||||
static std::string json_unescape(const std::string& s) {
|
||||
std::string result;
|
||||
result.reserve(s.size());
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
if (s[i] == '\\' && i + 1 < s.size()) {
|
||||
switch (s[i + 1]) {
|
||||
case '"': result += '"'; ++i; break;
|
||||
case '\\': result += '\\'; ++i; break;
|
||||
case 'n': result += '\n'; ++i; break;
|
||||
case 'r': result += '\r'; ++i; break;
|
||||
case 't': result += '\t'; ++i; break;
|
||||
default: result += s[i]; break;
|
||||
}
|
||||
} else {
|
||||
result += s[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool BookmarkManager::load() {
|
||||
bookmarks_.clear();
|
||||
|
||||
std::ifstream file(get_bookmarks_path());
|
||||
if (!file) {
|
||||
return false; // 文件不存在,这是正常的
|
||||
}
|
||||
|
||||
std::string content((std::istreambuf_iterator<char>(file)),
|
||||
std::istreambuf_iterator<char>());
|
||||
file.close();
|
||||
|
||||
// 简单的 JSON 解析
|
||||
// 格式: [{"url":"...","title":"...","time":123}, ...]
|
||||
size_t pos = content.find('[');
|
||||
if (pos == std::string::npos) return false;
|
||||
|
||||
pos++; // 跳过 '['
|
||||
|
||||
while (pos < content.size()) {
|
||||
// 查找对象开始
|
||||
pos = content.find('{', pos);
|
||||
if (pos == std::string::npos) break;
|
||||
pos++;
|
||||
|
||||
Bookmark bm;
|
||||
|
||||
// 解析字段
|
||||
while (pos < content.size() && content[pos] != '}') {
|
||||
// 跳过空白
|
||||
while (pos < content.size() && (content[pos] == ' ' || content[pos] == '\n' ||
|
||||
content[pos] == '\r' || content[pos] == '\t' || content[pos] == ',')) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (content[pos] == '}') break;
|
||||
|
||||
// 读取键名
|
||||
if (content[pos] != '"') { pos++; continue; }
|
||||
pos++; // 跳过 '"'
|
||||
|
||||
size_t key_end = content.find('"', pos);
|
||||
if (key_end == std::string::npos) break;
|
||||
std::string key = content.substr(pos, key_end - pos);
|
||||
pos = key_end + 1;
|
||||
|
||||
// 跳过 ':'
|
||||
pos = content.find(':', pos);
|
||||
if (pos == std::string::npos) break;
|
||||
pos++;
|
||||
|
||||
// 跳过空白
|
||||
while (pos < content.size() && (content[pos] == ' ' || content[pos] == '\n' ||
|
||||
content[pos] == '\r' || content[pos] == '\t')) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (content[pos] == '"') {
|
||||
// 字符串值
|
||||
pos++; // 跳过 '"'
|
||||
size_t val_end = pos;
|
||||
while (val_end < content.size()) {
|
||||
if (content[val_end] == '"' && content[val_end - 1] != '\\') break;
|
||||
val_end++;
|
||||
}
|
||||
std::string value = json_unescape(content.substr(pos, val_end - pos));
|
||||
pos = val_end + 1;
|
||||
|
||||
if (key == "url") bm.url = value;
|
||||
else if (key == "title") bm.title = value;
|
||||
} else {
|
||||
// 数字值
|
||||
size_t val_end = pos;
|
||||
while (val_end < content.size() && content[val_end] >= '0' && content[val_end] <= '9') {
|
||||
val_end++;
|
||||
}
|
||||
std::string value = content.substr(pos, val_end - pos);
|
||||
pos = val_end;
|
||||
|
||||
if (key == "time") {
|
||||
bm.added_time = std::stoll(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bm.url.empty()) {
|
||||
bookmarks_.push_back(bm);
|
||||
}
|
||||
|
||||
// 跳到下一个对象
|
||||
pos = content.find('}', pos);
|
||||
if (pos == std::string::npos) break;
|
||||
pos++;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookmarkManager::save() const {
|
||||
if (!ensure_config_dir()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ofstream file(get_bookmarks_path());
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file << "[\n";
|
||||
for (size_t i = 0; i < bookmarks_.size(); ++i) {
|
||||
const auto& bm = bookmarks_[i];
|
||||
file << " {\n";
|
||||
file << " \"url\": \"" << json_escape(bm.url) << "\",\n";
|
||||
file << " \"title\": \"" << json_escape(bm.title) << "\",\n";
|
||||
file << " \"time\": " << bm.added_time << "\n";
|
||||
file << " }";
|
||||
if (i + 1 < bookmarks_.size()) {
|
||||
file << ",";
|
||||
}
|
||||
file << "\n";
|
||||
}
|
||||
file << "]\n";
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookmarkManager::add(const std::string& url, const std::string& title) {
|
||||
// 检查是否已存在
|
||||
if (contains(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bookmarks_.emplace_back(url, title);
|
||||
return save();
|
||||
}
|
||||
|
||||
bool BookmarkManager::remove(const std::string& url) {
|
||||
auto it = std::find_if(bookmarks_.begin(), bookmarks_.end(),
|
||||
[&url](const Bookmark& bm) { return bm.url == url; });
|
||||
|
||||
if (it == bookmarks_.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bookmarks_.erase(it);
|
||||
return save();
|
||||
}
|
||||
|
||||
bool BookmarkManager::remove_at(size_t index) {
|
||||
if (index >= bookmarks_.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bookmarks_.erase(bookmarks_.begin() + index);
|
||||
return save();
|
||||
}
|
||||
|
||||
bool BookmarkManager::contains(const std::string& url) const {
|
||||
return std::find_if(bookmarks_.begin(), bookmarks_.end(),
|
||||
[&url](const Bookmark& bm) { return bm.url == url; })
|
||||
!= bookmarks_.end();
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
96
src/bookmark.h
Normal file
96
src/bookmark.h
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <ctime>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* 书签条目
|
||||
*/
|
||||
struct Bookmark {
|
||||
std::string url;
|
||||
std::string title;
|
||||
std::time_t added_time;
|
||||
|
||||
Bookmark() : added_time(0) {}
|
||||
Bookmark(const std::string& url, const std::string& title)
|
||||
: url(url), title(title), added_time(std::time(nullptr)) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* 书签管理器
|
||||
*
|
||||
* 书签存储在 ~/.config/tut/bookmarks.json
|
||||
*/
|
||||
class BookmarkManager {
|
||||
public:
|
||||
BookmarkManager();
|
||||
~BookmarkManager();
|
||||
|
||||
/**
|
||||
* 加载书签(从默认路径)
|
||||
*/
|
||||
bool load();
|
||||
|
||||
/**
|
||||
* 保存书签(到默认路径)
|
||||
*/
|
||||
bool save() const;
|
||||
|
||||
/**
|
||||
* 添加书签
|
||||
* @return true 如果添加成功,false 如果已存在
|
||||
*/
|
||||
bool add(const std::string& url, const std::string& title);
|
||||
|
||||
/**
|
||||
* 删除书签
|
||||
* @return true 如果删除成功
|
||||
*/
|
||||
bool remove(const std::string& url);
|
||||
|
||||
/**
|
||||
* 删除书签(按索引)
|
||||
*/
|
||||
bool remove_at(size_t index);
|
||||
|
||||
/**
|
||||
* 检查URL是否已收藏
|
||||
*/
|
||||
bool contains(const std::string& url) const;
|
||||
|
||||
/**
|
||||
* 获取书签列表
|
||||
*/
|
||||
const std::vector<Bookmark>& get_all() const { return bookmarks_; }
|
||||
|
||||
/**
|
||||
* 获取书签数量
|
||||
*/
|
||||
size_t count() const { return bookmarks_.size(); }
|
||||
|
||||
/**
|
||||
* 清空所有书签
|
||||
*/
|
||||
void clear() { bookmarks_.clear(); }
|
||||
|
||||
/**
|
||||
* 获取配置目录路径
|
||||
*/
|
||||
static std::string get_config_dir();
|
||||
|
||||
/**
|
||||
* 获取书签文件路径
|
||||
*/
|
||||
static std::string get_bookmarks_path();
|
||||
|
||||
private:
|
||||
std::vector<Bookmark> bookmarks_;
|
||||
|
||||
// 确保配置目录存在
|
||||
static bool ensure_config_dir();
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
1331
src/browser.cpp
1331
src/browser.cpp
File diff suppressed because it is too large
Load diff
|
|
@ -2,12 +2,20 @@
|
|||
|
||||
#include "http_client.h"
|
||||
#include "html_parser.h"
|
||||
#include "text_renderer.h"
|
||||
#include "input_handler.h"
|
||||
#include "render/terminal.h"
|
||||
#include "render/renderer.h"
|
||||
#include "render/layout.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* Browser - TUT 终端浏览器
|
||||
*
|
||||
* 使用 Terminal + FrameBuffer + Renderer + LayoutEngine 架构
|
||||
* 支持 True Color, Unicode, 差分渲染
|
||||
*/
|
||||
class Browser {
|
||||
public:
|
||||
Browser();
|
||||
|
|
|
|||
|
|
@ -1,630 +0,0 @@
|
|||
#include "browser_v2.h"
|
||||
#include "dom_tree.h"
|
||||
#include "render/colors.h"
|
||||
#include "render/decorations.h"
|
||||
#include "utils/unicode.h"
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <map>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <chrono>
|
||||
#include <ncurses.h>
|
||||
|
||||
using namespace tut;
|
||||
|
||||
// 缓存条目
|
||||
struct CacheEntry {
|
||||
DocumentTree tree;
|
||||
std::string html;
|
||||
std::chrono::steady_clock::time_point timestamp;
|
||||
|
||||
bool is_expired(int max_age_seconds = 300) const {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto age = std::chrono::duration_cast<std::chrono::seconds>(now - timestamp).count();
|
||||
return age > max_age_seconds;
|
||||
}
|
||||
};
|
||||
|
||||
class BrowserV2::Impl {
|
||||
public:
|
||||
// 网络和解析
|
||||
HttpClient http_client;
|
||||
HtmlParser html_parser;
|
||||
InputHandler input_handler;
|
||||
|
||||
// 新渲染系统
|
||||
Terminal terminal;
|
||||
std::unique_ptr<FrameBuffer> framebuffer;
|
||||
std::unique_ptr<Renderer> renderer;
|
||||
std::unique_ptr<LayoutEngine> layout_engine;
|
||||
|
||||
// 文档状态
|
||||
DocumentTree current_tree;
|
||||
LayoutResult current_layout;
|
||||
std::string current_url;
|
||||
std::vector<std::string> history;
|
||||
int history_pos = -1;
|
||||
|
||||
// 视图状态
|
||||
int scroll_pos = 0;
|
||||
int active_link = -1;
|
||||
int active_field = -1;
|
||||
std::string status_message;
|
||||
std::string search_term;
|
||||
|
||||
int screen_width = 0;
|
||||
int screen_height = 0;
|
||||
|
||||
// Marks support
|
||||
std::map<char, int> marks;
|
||||
|
||||
// 搜索相关
|
||||
SearchContext search_ctx;
|
||||
|
||||
// 页面缓存
|
||||
std::map<std::string, CacheEntry> page_cache;
|
||||
static constexpr int CACHE_MAX_AGE = 300; // 5分钟缓存
|
||||
static constexpr size_t CACHE_MAX_SIZE = 20; // 最多缓存20个页面
|
||||
|
||||
bool init_screen() {
|
||||
if (!terminal.init()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
terminal.get_size(screen_width, screen_height);
|
||||
terminal.use_alternate_screen(true);
|
||||
terminal.hide_cursor();
|
||||
|
||||
// 创建渲染组件
|
||||
framebuffer = std::make_unique<FrameBuffer>(screen_width, screen_height);
|
||||
renderer = std::make_unique<Renderer>(terminal);
|
||||
layout_engine = std::make_unique<LayoutEngine>(screen_width);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void cleanup_screen() {
|
||||
terminal.show_cursor();
|
||||
terminal.use_alternate_screen(false);
|
||||
terminal.cleanup();
|
||||
}
|
||||
|
||||
void handle_resize() {
|
||||
terminal.get_size(screen_width, screen_height);
|
||||
framebuffer = std::make_unique<FrameBuffer>(screen_width, screen_height);
|
||||
layout_engine->set_viewport_width(screen_width);
|
||||
|
||||
// 重新布局当前文档
|
||||
if (current_tree.root) {
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
}
|
||||
|
||||
renderer->force_redraw();
|
||||
}
|
||||
|
||||
bool load_page(const std::string& url, bool force_refresh = false) {
|
||||
// 检查缓存
|
||||
auto cache_it = page_cache.find(url);
|
||||
bool use_cache = !force_refresh && cache_it != page_cache.end() &&
|
||||
!cache_it->second.is_expired(CACHE_MAX_AGE);
|
||||
|
||||
if (use_cache) {
|
||||
status_message = "⚡ Loading from cache...";
|
||||
draw_screen();
|
||||
|
||||
// 使用缓存的文档树
|
||||
// 注意:需要重新解析因为DocumentTree包含unique_ptr
|
||||
current_tree = html_parser.parse_tree(cache_it->second.html, url);
|
||||
status_message = "⚡ " + (current_tree.title.empty() ? url : current_tree.title);
|
||||
} else {
|
||||
status_message = "⏳ Connecting to " + extract_host(url) + "...";
|
||||
draw_screen();
|
||||
|
||||
auto response = http_client.fetch(url);
|
||||
|
||||
if (!response.is_success()) {
|
||||
status_message = "❌ " + (response.error_message.empty() ?
|
||||
"HTTP " + std::to_string(response.status_code) :
|
||||
response.error_message);
|
||||
return false;
|
||||
}
|
||||
|
||||
status_message = "📄 Parsing HTML...";
|
||||
draw_screen();
|
||||
|
||||
// 解析HTML
|
||||
current_tree = html_parser.parse_tree(response.body, url);
|
||||
|
||||
// 添加到缓存
|
||||
add_to_cache(url, response.body);
|
||||
|
||||
status_message = current_tree.title.empty() ? url : current_tree.title;
|
||||
}
|
||||
|
||||
// 布局计算
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
|
||||
current_url = url;
|
||||
scroll_pos = 0;
|
||||
active_link = current_tree.links.empty() ? -1 : 0;
|
||||
active_field = current_tree.form_fields.empty() ? -1 : 0;
|
||||
search_ctx = SearchContext(); // 清除搜索状态
|
||||
search_term.clear();
|
||||
|
||||
// 更新历史(仅在非刷新时)
|
||||
if (!force_refresh) {
|
||||
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
|
||||
history.erase(history.begin() + history_pos + 1, history.end());
|
||||
}
|
||||
history.push_back(url);
|
||||
history_pos = history.size() - 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void add_to_cache(const std::string& url, const std::string& html) {
|
||||
// 限制缓存大小
|
||||
if (page_cache.size() >= CACHE_MAX_SIZE) {
|
||||
// 移除最老的缓存条目
|
||||
auto oldest = page_cache.begin();
|
||||
for (auto it = page_cache.begin(); it != page_cache.end(); ++it) {
|
||||
if (it->second.timestamp < oldest->second.timestamp) {
|
||||
oldest = it;
|
||||
}
|
||||
}
|
||||
page_cache.erase(oldest);
|
||||
}
|
||||
|
||||
CacheEntry entry;
|
||||
entry.html = html;
|
||||
entry.timestamp = std::chrono::steady_clock::now();
|
||||
page_cache[url] = std::move(entry);
|
||||
}
|
||||
|
||||
// 从URL中提取主机名
|
||||
std::string extract_host(const std::string& url) {
|
||||
// 简单提取:找到://之后的部分,到第一个/为止
|
||||
size_t proto_end = url.find("://");
|
||||
if (proto_end == std::string::npos) {
|
||||
return url;
|
||||
}
|
||||
size_t host_start = proto_end + 3;
|
||||
size_t host_end = url.find('/', host_start);
|
||||
if (host_end == std::string::npos) {
|
||||
return url.substr(host_start);
|
||||
}
|
||||
return url.substr(host_start, host_end - host_start);
|
||||
}
|
||||
|
||||
void draw_screen() {
|
||||
// 清空缓冲区
|
||||
framebuffer->clear_with_color(colors::BG_PRIMARY);
|
||||
|
||||
int content_height = screen_height - 1; // 留出状态栏
|
||||
|
||||
// 渲染文档内容
|
||||
RenderContext render_ctx;
|
||||
render_ctx.active_link = active_link;
|
||||
render_ctx.active_field = active_field;
|
||||
render_ctx.search = search_ctx.enabled ? &search_ctx : nullptr;
|
||||
|
||||
DocumentRenderer doc_renderer(*framebuffer);
|
||||
doc_renderer.render(current_layout, scroll_pos, render_ctx);
|
||||
|
||||
// 渲染状态栏
|
||||
draw_status_bar(content_height);
|
||||
|
||||
// 渲染到终端
|
||||
renderer->render(*framebuffer);
|
||||
}
|
||||
|
||||
void draw_status_bar(int y) {
|
||||
// 状态栏背景
|
||||
for (int x = 0; x < screen_width; ++x) {
|
||||
framebuffer->set_cell(x, y, Cell{" ", colors::STATUSBAR_FG, colors::STATUSBAR_BG, ATTR_NONE});
|
||||
}
|
||||
|
||||
// 左侧: 模式
|
||||
std::string mode_str;
|
||||
InputMode mode = input_handler.get_mode();
|
||||
switch (mode) {
|
||||
case InputMode::NORMAL: mode_str = "NORMAL"; break;
|
||||
case InputMode::COMMAND:
|
||||
case InputMode::SEARCH: mode_str = input_handler.get_buffer(); break;
|
||||
default: mode_str = ""; break;
|
||||
}
|
||||
framebuffer->set_text(1, y, mode_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
||||
|
||||
// 中间: 状态消息或链接URL
|
||||
std::string display_msg;
|
||||
if (mode == InputMode::NORMAL) {
|
||||
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
|
||||
display_msg = current_tree.links[active_link].url;
|
||||
}
|
||||
if (display_msg.empty()) {
|
||||
display_msg = status_message;
|
||||
}
|
||||
|
||||
if (!display_msg.empty()) {
|
||||
// 截断过长的消息
|
||||
size_t max_len = screen_width - mode_str.length() - 20;
|
||||
if (display_msg.length() > max_len) {
|
||||
display_msg = display_msg.substr(0, max_len - 3) + "...";
|
||||
}
|
||||
int msg_x = static_cast<int>(mode_str.length()) + 3;
|
||||
framebuffer->set_text(msg_x, y, display_msg, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧: 位置信息
|
||||
int total_lines = current_layout.total_lines;
|
||||
int visible_lines = screen_height - 1;
|
||||
int percentage = (total_lines > 0 && scroll_pos + visible_lines < total_lines) ?
|
||||
(scroll_pos * 100) / total_lines : 100;
|
||||
if (total_lines == 0) percentage = 0;
|
||||
|
||||
std::string pos_str = std::to_string(scroll_pos + 1) + "/" +
|
||||
std::to_string(total_lines) + " " +
|
||||
std::to_string(percentage) + "%";
|
||||
int pos_x = screen_width - static_cast<int>(pos_str.length()) - 1;
|
||||
framebuffer->set_text(pos_x, y, pos_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
||||
}
|
||||
|
||||
void handle_action(const InputResult& result) {
|
||||
int visible_lines = screen_height - 1;
|
||||
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
|
||||
int count = result.has_count ? result.count : 1;
|
||||
|
||||
switch (result.action) {
|
||||
case Action::SCROLL_UP:
|
||||
scroll_pos = std::max(0, scroll_pos - count);
|
||||
break;
|
||||
case Action::SCROLL_DOWN:
|
||||
scroll_pos = std::min(max_scroll, scroll_pos + count);
|
||||
break;
|
||||
case Action::SCROLL_PAGE_UP:
|
||||
scroll_pos = std::max(0, scroll_pos - visible_lines);
|
||||
break;
|
||||
case Action::SCROLL_PAGE_DOWN:
|
||||
scroll_pos = std::min(max_scroll, scroll_pos + visible_lines);
|
||||
break;
|
||||
case Action::GOTO_TOP:
|
||||
scroll_pos = 0;
|
||||
break;
|
||||
case Action::GOTO_BOTTOM:
|
||||
scroll_pos = max_scroll;
|
||||
break;
|
||||
case Action::GOTO_LINE:
|
||||
if (result.number > 0) {
|
||||
scroll_pos = std::min(result.number - 1, max_scroll);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::NEXT_LINK:
|
||||
if (!current_tree.links.empty()) {
|
||||
active_link = (active_link + 1) % current_tree.links.size();
|
||||
scroll_to_link(active_link);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::PREV_LINK:
|
||||
if (!current_tree.links.empty()) {
|
||||
active_link = (active_link - 1 + current_tree.links.size()) % current_tree.links.size();
|
||||
scroll_to_link(active_link);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::FOLLOW_LINK:
|
||||
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
|
||||
load_page(current_tree.links[active_link].url);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::GO_BACK:
|
||||
if (history_pos > 0) {
|
||||
history_pos--;
|
||||
load_page(history[history_pos]);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::GO_FORWARD:
|
||||
if (history_pos < static_cast<int>(history.size()) - 1) {
|
||||
history_pos++;
|
||||
load_page(history[history_pos]);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::OPEN_URL:
|
||||
if (!result.text.empty()) {
|
||||
load_page(result.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::REFRESH:
|
||||
if (!current_url.empty()) {
|
||||
load_page(current_url, true); // 强制刷新,跳过缓存
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::SEARCH_FORWARD: {
|
||||
int count = perform_search(result.text);
|
||||
if (count > 0) {
|
||||
status_message = "Match 1/" + std::to_string(count);
|
||||
} else if (!result.text.empty()) {
|
||||
status_message = "Pattern not found: " + result.text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Action::SEARCH_NEXT:
|
||||
search_next();
|
||||
break;
|
||||
|
||||
case Action::SEARCH_PREV:
|
||||
search_prev();
|
||||
break;
|
||||
|
||||
case Action::HELP:
|
||||
show_help();
|
||||
break;
|
||||
|
||||
case Action::QUIT:
|
||||
break; // 在main loop处理
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行搜索,返回匹配数量
|
||||
int perform_search(const std::string& term) {
|
||||
search_ctx.matches.clear();
|
||||
search_ctx.current_match_idx = -1;
|
||||
search_ctx.enabled = false;
|
||||
|
||||
if (term.empty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
search_term = term;
|
||||
search_ctx.enabled = true;
|
||||
|
||||
// 遍历所有布局块和行,查找匹配
|
||||
int doc_line = 0;
|
||||
for (const auto& block : current_layout.blocks) {
|
||||
// 上边距
|
||||
doc_line += block.margin_top;
|
||||
|
||||
// 内容行
|
||||
for (const auto& line : block.lines) {
|
||||
// 构建整行文本用于搜索
|
||||
std::string line_text;
|
||||
|
||||
for (const auto& span : line.spans) {
|
||||
line_text += span.text;
|
||||
}
|
||||
|
||||
// 搜索匹配(大小写不敏感)
|
||||
std::string lower_line = line_text;
|
||||
std::string lower_term = term;
|
||||
std::transform(lower_line.begin(), lower_line.end(), lower_line.begin(), ::tolower);
|
||||
std::transform(lower_term.begin(), lower_term.end(), lower_term.begin(), ::tolower);
|
||||
|
||||
size_t pos = 0;
|
||||
while ((pos = lower_line.find(lower_term, pos)) != std::string::npos) {
|
||||
SearchMatch match;
|
||||
match.line = doc_line;
|
||||
match.start_col = line.indent + static_cast<int>(pos);
|
||||
match.length = static_cast<int>(term.length());
|
||||
search_ctx.matches.push_back(match);
|
||||
pos += 1; // 继续搜索下一个匹配
|
||||
}
|
||||
|
||||
doc_line++;
|
||||
}
|
||||
|
||||
// 下边距
|
||||
doc_line += block.margin_bottom;
|
||||
}
|
||||
|
||||
// 如果有匹配,跳转到第一个
|
||||
if (!search_ctx.matches.empty()) {
|
||||
search_ctx.current_match_idx = 0;
|
||||
scroll_to_match(0);
|
||||
}
|
||||
|
||||
return static_cast<int>(search_ctx.matches.size());
|
||||
}
|
||||
|
||||
// 跳转到指定匹配
|
||||
void scroll_to_match(int idx) {
|
||||
if (idx < 0 || idx >= static_cast<int>(search_ctx.matches.size())) {
|
||||
return;
|
||||
}
|
||||
|
||||
search_ctx.current_match_idx = idx;
|
||||
int match_line = search_ctx.matches[idx].line;
|
||||
int visible_lines = screen_height - 1;
|
||||
|
||||
// 确保匹配行在可见区域
|
||||
if (match_line < scroll_pos) {
|
||||
scroll_pos = match_line;
|
||||
} else if (match_line >= scroll_pos + visible_lines) {
|
||||
scroll_pos = match_line - visible_lines / 2;
|
||||
}
|
||||
|
||||
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
|
||||
scroll_pos = std::max(0, std::min(scroll_pos, max_scroll));
|
||||
}
|
||||
|
||||
// 搜索下一个
|
||||
void search_next() {
|
||||
if (search_ctx.matches.empty()) {
|
||||
if (!search_term.empty()) {
|
||||
status_message = "Pattern not found: " + search_term;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
search_ctx.current_match_idx = (search_ctx.current_match_idx + 1) % search_ctx.matches.size();
|
||||
scroll_to_match(search_ctx.current_match_idx);
|
||||
status_message = "Match " + std::to_string(search_ctx.current_match_idx + 1) +
|
||||
"/" + std::to_string(search_ctx.matches.size());
|
||||
}
|
||||
|
||||
// 搜索上一个
|
||||
void search_prev() {
|
||||
if (search_ctx.matches.empty()) {
|
||||
if (!search_term.empty()) {
|
||||
status_message = "Pattern not found: " + search_term;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
search_ctx.current_match_idx = (search_ctx.current_match_idx - 1 + search_ctx.matches.size()) % search_ctx.matches.size();
|
||||
scroll_to_match(search_ctx.current_match_idx);
|
||||
status_message = "Match " + std::to_string(search_ctx.current_match_idx + 1) +
|
||||
"/" + std::to_string(search_ctx.matches.size());
|
||||
}
|
||||
|
||||
// 滚动到链接位置
|
||||
void scroll_to_link(int link_idx) {
|
||||
if (link_idx < 0 || link_idx >= static_cast<int>(current_layout.link_positions.size())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& pos = current_layout.link_positions[link_idx];
|
||||
if (pos.start_line < 0) {
|
||||
return; // 链接位置无效
|
||||
}
|
||||
|
||||
int visible_lines = screen_height - 1;
|
||||
int link_line = pos.start_line;
|
||||
|
||||
// 确保链接行在可见区域
|
||||
if (link_line < scroll_pos) {
|
||||
// 链接在视口上方,滚动使其出现在顶部附近
|
||||
scroll_pos = std::max(0, link_line - 2);
|
||||
} else if (link_line >= scroll_pos + visible_lines) {
|
||||
// 链接在视口下方,滚动使其出现在中间
|
||||
scroll_pos = link_line - visible_lines / 2;
|
||||
}
|
||||
|
||||
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
|
||||
scroll_pos = std::max(0, std::min(scroll_pos, max_scroll));
|
||||
}
|
||||
|
||||
void show_help() {
|
||||
std::string help_html = R"(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>TUT 2.0 Help</title></head>
|
||||
<body>
|
||||
<h1>TUT 2.0 - Terminal Browser</h1>
|
||||
|
||||
<h2>Navigation</h2>
|
||||
<ul>
|
||||
<li>j/k - Scroll down/up</li>
|
||||
<li>Ctrl+d/Ctrl+u - Page down/up</li>
|
||||
<li>gg - Go to top</li>
|
||||
<li>G - Go to bottom</li>
|
||||
</ul>
|
||||
|
||||
<h2>Links</h2>
|
||||
<ul>
|
||||
<li>Tab - Next link</li>
|
||||
<li>Shift+Tab - Previous link</li>
|
||||
<li>Enter - Follow link</li>
|
||||
</ul>
|
||||
|
||||
<h2>History</h2>
|
||||
<ul>
|
||||
<li>h - Go back</li>
|
||||
<li>l - Go forward</li>
|
||||
</ul>
|
||||
|
||||
<h2>Search</h2>
|
||||
<ul>
|
||||
<li>/ - Search forward</li>
|
||||
<li>n - Next match</li>
|
||||
<li>N - Previous match</li>
|
||||
</ul>
|
||||
|
||||
<h2>Commands</h2>
|
||||
<ul>
|
||||
<li>:o URL - Open URL</li>
|
||||
<li>:q - Quit</li>
|
||||
<li>? - Show this help</li>
|
||||
</ul>
|
||||
|
||||
<h2>Forms</h2>
|
||||
<ul>
|
||||
<li>Tab - Navigate links and form fields</li>
|
||||
<li>Enter - Activate link or submit form</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
<p>TUT 2.0 - A modern terminal browser with True Color support</p>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
current_tree = html_parser.parse_tree(help_html, "help://");
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
scroll_pos = 0;
|
||||
active_link = current_tree.links.empty() ? -1 : 0;
|
||||
status_message = "Help - Press any key to continue";
|
||||
}
|
||||
};
|
||||
|
||||
BrowserV2::BrowserV2() : pImpl(std::make_unique<Impl>()) {
|
||||
pImpl->input_handler.set_status_callback([this](const std::string& msg) {
|
||||
pImpl->status_message = msg;
|
||||
});
|
||||
}
|
||||
|
||||
BrowserV2::~BrowserV2() = default;
|
||||
|
||||
void BrowserV2::run(const std::string& initial_url) {
|
||||
if (!pImpl->init_screen()) {
|
||||
throw std::runtime_error("Failed to initialize terminal");
|
||||
}
|
||||
|
||||
if (!initial_url.empty()) {
|
||||
load_url(initial_url);
|
||||
} else {
|
||||
pImpl->show_help();
|
||||
}
|
||||
|
||||
bool running = true;
|
||||
while (running) {
|
||||
pImpl->draw_screen();
|
||||
|
||||
int ch = pImpl->terminal.get_key(50);
|
||||
if (ch == -1) continue;
|
||||
|
||||
// 处理窗口大小变化
|
||||
if (ch == KEY_RESIZE) {
|
||||
pImpl->handle_resize();
|
||||
continue;
|
||||
}
|
||||
|
||||
auto result = pImpl->input_handler.handle_key(ch);
|
||||
if (result.action == Action::QUIT) {
|
||||
running = false;
|
||||
} else if (result.action != Action::NONE) {
|
||||
pImpl->handle_action(result);
|
||||
}
|
||||
}
|
||||
|
||||
pImpl->cleanup_screen();
|
||||
}
|
||||
|
||||
bool BrowserV2::load_url(const std::string& url) {
|
||||
return pImpl->load_page(url);
|
||||
}
|
||||
|
||||
std::string BrowserV2::get_current_url() const {
|
||||
return pImpl->current_url;
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "http_client.h"
|
||||
#include "html_parser.h"
|
||||
#include "input_handler.h"
|
||||
#include "render/terminal.h"
|
||||
#include "render/renderer.h"
|
||||
#include "render/layout.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* BrowserV2 - 使用新渲染系统的浏览器
|
||||
*
|
||||
* 使用 Terminal + FrameBuffer + Renderer + LayoutEngine 架构
|
||||
* 支持 True Color, Unicode, 差分渲染
|
||||
*/
|
||||
class BrowserV2 {
|
||||
public:
|
||||
BrowserV2();
|
||||
~BrowserV2();
|
||||
|
||||
void run(const std::string& initial_url = "");
|
||||
bool load_url(const std::string& url);
|
||||
std::string get_current_url() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
|
@ -48,7 +48,12 @@ bool DomNode::is_block_element() const {
|
|||
tag_name == "pre" || tag_name == "hr" ||
|
||||
tag_name == "table" || tag_name == "tr" ||
|
||||
tag_name == "th" || tag_name == "td" ||
|
||||
tag_name == "form" || tag_name == "fieldset";
|
||||
tag_name == "tbody" || tag_name == "thead" ||
|
||||
tag_name == "tfoot" || tag_name == "caption" ||
|
||||
tag_name == "form" || tag_name == "fieldset" ||
|
||||
tag_name == "figure" || tag_name == "figcaption" ||
|
||||
tag_name == "details" || tag_name == "summary" ||
|
||||
tag_name == "center" || tag_name == "address";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +96,11 @@ bool DomNode::should_render() const {
|
|||
std::string DomNode::get_all_text() const {
|
||||
std::string result;
|
||||
|
||||
// 过滤不应该提取文本的元素
|
||||
if (!should_render()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (node_type == NodeType::TEXT) {
|
||||
result = text_content;
|
||||
} else {
|
||||
|
|
@ -136,7 +146,7 @@ DocumentTree DomTreeBuilder::build(const std::string& html, const std::string& b
|
|||
// 2. 转换为DomNode树
|
||||
DocumentTree tree;
|
||||
tree.url = base_url;
|
||||
tree.root = convert_node(output->root, tree.links, tree.form_fields, base_url);
|
||||
tree.root = convert_node(output->root, tree.links, tree.form_fields, tree.images, base_url);
|
||||
|
||||
// 3. 提取标题
|
||||
if (tree.root) {
|
||||
|
|
@ -153,6 +163,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
|||
GumboNode* gumbo_node,
|
||||
std::vector<Link>& links,
|
||||
std::vector<DomNode*>& form_fields,
|
||||
std::vector<DomNode*>& images,
|
||||
const std::string& base_url
|
||||
) {
|
||||
if (!gumbo_node) return nullptr;
|
||||
|
|
@ -167,6 +178,12 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
|||
node->tag_name = gumbo_normalized_tagname(element.tag);
|
||||
node->element_type = map_gumbo_tag_to_element_type(element.tag);
|
||||
|
||||
// 跳过 script、style 等不需要渲染的标签(包括其所有子节点)
|
||||
if (element.tag == GUMBO_TAG_SCRIPT || element.tag == GUMBO_TAG_STYLE ||
|
||||
element.tag == GUMBO_TAG_NOSCRIPT || element.tag == GUMBO_TAG_TEMPLATE) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Assign current form ID to children
|
||||
node->form_id = g_current_form_id;
|
||||
|
||||
|
|
@ -282,6 +299,11 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
|||
if (height_attr && height_attr->value) {
|
||||
try { node->img_height = std::stoi(height_attr->value); } catch (...) {}
|
||||
}
|
||||
|
||||
// 添加到图片列表(用于后续下载)
|
||||
if (!node->img_src.empty()) {
|
||||
images.push_back(node.get());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -334,12 +356,13 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
|||
static_cast<GumboNode*>(children->data[i]),
|
||||
links,
|
||||
form_fields,
|
||||
images,
|
||||
base_url
|
||||
);
|
||||
if (child) {
|
||||
child->parent = node.get();
|
||||
node->children.push_back(std::move(child));
|
||||
|
||||
|
||||
// For TEXTAREA, content is value
|
||||
if (element.tag == GUMBO_TAG_TEXTAREA && child->node_type == NodeType::TEXT) {
|
||||
node->value += child->text_content;
|
||||
|
|
@ -371,6 +394,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
|||
static_cast<GumboNode*>(doc.children.data[i]),
|
||||
links,
|
||||
form_fields,
|
||||
images,
|
||||
base_url
|
||||
);
|
||||
if (child) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include "html_parser.h"
|
||||
#include "render/image.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
|
@ -40,6 +41,7 @@ struct DomNode {
|
|||
std::string alt_text; // 图片alt文本
|
||||
int img_width = -1; // 图片宽度 (-1表示未指定)
|
||||
int img_height = -1; // 图片高度 (-1表示未指定)
|
||||
tut::ImageData image_data; // 解码后的图片数据
|
||||
|
||||
// 表格属性
|
||||
bool is_table_header = false;
|
||||
|
|
@ -68,6 +70,7 @@ struct DocumentTree {
|
|||
std::unique_ptr<DomNode> root;
|
||||
std::vector<Link> links; // 全局链接列表
|
||||
std::vector<DomNode*> form_fields; // 全局表单字段列表 (非拥有指针)
|
||||
std::vector<DomNode*> images; // 全局图片列表 (非拥有指针)
|
||||
std::string title;
|
||||
std::string url;
|
||||
};
|
||||
|
|
@ -87,6 +90,7 @@ private:
|
|||
GumboNode* gumbo_node,
|
||||
std::vector<Link>& links,
|
||||
std::vector<DomNode*>& form_fields,
|
||||
std::vector<DomNode*>& images,
|
||||
const std::string& base_url
|
||||
);
|
||||
|
||||
|
|
|
|||
217
src/history.cpp
Normal file
217
src/history.cpp
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
#include "history.h"
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <sys/stat.h>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace tut {
|
||||
|
||||
HistoryManager::HistoryManager() {
|
||||
load();
|
||||
}
|
||||
|
||||
HistoryManager::~HistoryManager() {
|
||||
save();
|
||||
}
|
||||
|
||||
std::string HistoryManager::get_history_path() {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home) {
|
||||
home = "/tmp";
|
||||
}
|
||||
return std::string(home) + "/.config/tut/history.json";
|
||||
}
|
||||
|
||||
bool HistoryManager::ensure_config_dir() {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home) home = "/tmp";
|
||||
|
||||
std::string config_dir = std::string(home) + "/.config";
|
||||
std::string tut_dir = config_dir + "/tut";
|
||||
|
||||
struct stat st;
|
||||
if (stat(tut_dir.c_str(), &st) == 0) {
|
||||
return S_ISDIR(st.st_mode);
|
||||
}
|
||||
|
||||
mkdir(config_dir.c_str(), 0755);
|
||||
return mkdir(tut_dir.c_str(), 0755) == 0 || errno == EEXIST;
|
||||
}
|
||||
|
||||
// JSON escape/unescape
|
||||
static std::string json_escape(const std::string& s) {
|
||||
std::string result;
|
||||
result.reserve(s.size() + 10);
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': result += "\\\""; break;
|
||||
case '\\': result += "\\\\"; break;
|
||||
case '\n': result += "\\n"; break;
|
||||
case '\r': result += "\\r"; break;
|
||||
case '\t': result += "\\t"; break;
|
||||
default: result += c; break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::string json_unescape(const std::string& s) {
|
||||
std::string result;
|
||||
result.reserve(s.size());
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
if (s[i] == '\\' && i + 1 < s.size()) {
|
||||
switch (s[i + 1]) {
|
||||
case '"': result += '"'; ++i; break;
|
||||
case '\\': result += '\\'; ++i; break;
|
||||
case 'n': result += '\n'; ++i; break;
|
||||
case 'r': result += '\r'; ++i; break;
|
||||
case 't': result += '\t'; ++i; break;
|
||||
default: result += s[i]; break;
|
||||
}
|
||||
} else {
|
||||
result += s[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool HistoryManager::load() {
|
||||
entries_.clear();
|
||||
|
||||
std::ifstream file(get_history_path());
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string content((std::istreambuf_iterator<char>(file)),
|
||||
std::istreambuf_iterator<char>());
|
||||
file.close();
|
||||
|
||||
size_t pos = content.find('[');
|
||||
if (pos == std::string::npos) return false;
|
||||
pos++;
|
||||
|
||||
while (pos < content.size()) {
|
||||
pos = content.find('{', pos);
|
||||
if (pos == std::string::npos) break;
|
||||
pos++;
|
||||
|
||||
HistoryEntry entry;
|
||||
|
||||
while (pos < content.size() && content[pos] != '}') {
|
||||
while (pos < content.size() && (content[pos] == ' ' || content[pos] == '\n' ||
|
||||
content[pos] == '\r' || content[pos] == '\t' || content[pos] == ',')) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (content[pos] == '}') break;
|
||||
|
||||
if (content[pos] != '"') { pos++; continue; }
|
||||
pos++;
|
||||
|
||||
size_t key_end = content.find('"', pos);
|
||||
if (key_end == std::string::npos) break;
|
||||
std::string key = content.substr(pos, key_end - pos);
|
||||
pos = key_end + 1;
|
||||
|
||||
pos = content.find(':', pos);
|
||||
if (pos == std::string::npos) break;
|
||||
pos++;
|
||||
|
||||
while (pos < content.size() && (content[pos] == ' ' || content[pos] == '\n' ||
|
||||
content[pos] == '\r' || content[pos] == '\t')) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (content[pos] == '"') {
|
||||
pos++;
|
||||
size_t val_end = pos;
|
||||
while (val_end < content.size()) {
|
||||
if (content[val_end] == '"' && content[val_end - 1] != '\\') break;
|
||||
val_end++;
|
||||
}
|
||||
std::string value = json_unescape(content.substr(pos, val_end - pos));
|
||||
pos = val_end + 1;
|
||||
|
||||
if (key == "url") entry.url = value;
|
||||
else if (key == "title") entry.title = value;
|
||||
} else {
|
||||
size_t val_end = pos;
|
||||
while (val_end < content.size() && content[val_end] >= '0' && content[val_end] <= '9') {
|
||||
val_end++;
|
||||
}
|
||||
std::string value = content.substr(pos, val_end - pos);
|
||||
pos = val_end;
|
||||
|
||||
if (key == "time") {
|
||||
entry.visit_time = std::stoll(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.url.empty()) {
|
||||
entries_.push_back(entry);
|
||||
}
|
||||
|
||||
pos = content.find('}', pos);
|
||||
if (pos == std::string::npos) break;
|
||||
pos++;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HistoryManager::save() const {
|
||||
if (!ensure_config_dir()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ofstream file(get_history_path());
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file << "[\n";
|
||||
for (size_t i = 0; i < entries_.size(); ++i) {
|
||||
const auto& entry = entries_[i];
|
||||
file << " {\n";
|
||||
file << " \"url\": \"" << json_escape(entry.url) << "\",\n";
|
||||
file << " \"title\": \"" << json_escape(entry.title) << "\",\n";
|
||||
file << " \"time\": " << entry.visit_time << "\n";
|
||||
file << " }";
|
||||
if (i + 1 < entries_.size()) {
|
||||
file << ",";
|
||||
}
|
||||
file << "\n";
|
||||
}
|
||||
file << "]\n";
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void HistoryManager::add(const std::string& url, const std::string& title) {
|
||||
// Remove existing entry with same URL
|
||||
auto it = std::find_if(entries_.begin(), entries_.end(),
|
||||
[&url](const HistoryEntry& e) { return e.url == url; });
|
||||
if (it != entries_.end()) {
|
||||
entries_.erase(it);
|
||||
}
|
||||
|
||||
// Add new entry at the front
|
||||
entries_.insert(entries_.begin(), HistoryEntry(url, title));
|
||||
|
||||
// Enforce max entries limit
|
||||
if (entries_.size() > MAX_ENTRIES) {
|
||||
entries_.resize(MAX_ENTRIES);
|
||||
}
|
||||
|
||||
save();
|
||||
}
|
||||
|
||||
void HistoryManager::clear() {
|
||||
entries_.clear();
|
||||
save();
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
78
src/history.h
Normal file
78
src/history.h
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <ctime>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* 历史记录条目
|
||||
*/
|
||||
struct HistoryEntry {
|
||||
std::string url;
|
||||
std::string title;
|
||||
std::time_t visit_time;
|
||||
|
||||
HistoryEntry() : visit_time(0) {}
|
||||
HistoryEntry(const std::string& url, const std::string& title)
|
||||
: url(url), title(title), visit_time(std::time(nullptr)) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* 历史记录管理器
|
||||
*
|
||||
* 历史记录存储在 ~/.config/tut/history.json
|
||||
* 最多保存 MAX_ENTRIES 条记录
|
||||
*/
|
||||
class HistoryManager {
|
||||
public:
|
||||
static constexpr size_t MAX_ENTRIES = 1000;
|
||||
|
||||
HistoryManager();
|
||||
~HistoryManager();
|
||||
|
||||
/**
|
||||
* 加载历史记录
|
||||
*/
|
||||
bool load();
|
||||
|
||||
/**
|
||||
* 保存历史记录
|
||||
*/
|
||||
bool save() const;
|
||||
|
||||
/**
|
||||
* 添加历史记录
|
||||
* 如果 URL 已存在,会更新访问时间并移到最前面
|
||||
*/
|
||||
void add(const std::string& url, const std::string& title);
|
||||
|
||||
/**
|
||||
* 清空历史记录
|
||||
*/
|
||||
void clear();
|
||||
|
||||
/**
|
||||
* 获取历史记录列表(最新的在前面)
|
||||
*/
|
||||
const std::vector<HistoryEntry>& get_all() const { return entries_; }
|
||||
|
||||
/**
|
||||
* 获取历史记录数量
|
||||
*/
|
||||
size_t count() const { return entries_.size(); }
|
||||
|
||||
/**
|
||||
* 获取历史记录文件路径
|
||||
*/
|
||||
static std::string get_history_path();
|
||||
|
||||
private:
|
||||
std::vector<HistoryEntry> entries_;
|
||||
|
||||
// 确保配置目录存在
|
||||
static bool ensure_config_dir();
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -25,8 +25,15 @@ public:
|
|||
bool follow_redirects;
|
||||
std::string cookie_file;
|
||||
|
||||
// 异步请求相关
|
||||
CURLM* multi_handle = nullptr;
|
||||
CURL* async_easy = nullptr;
|
||||
AsyncState async_state = AsyncState::IDLE;
|
||||
std::string async_response_body;
|
||||
HttpResponse async_result;
|
||||
|
||||
Impl() : timeout(30),
|
||||
user_agent("TUT-Browser/1.0 (Terminal User Interface Browser)"),
|
||||
user_agent("TUT-Browser/2.0 (Terminal User Interface Browser)"),
|
||||
follow_redirects(true) {
|
||||
curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
|
|
@ -36,13 +43,59 @@ public:
|
|||
curl_easy_setopt(curl, CURLOPT_COOKIEFILE, "");
|
||||
// Enable automatic decompression of supported encodings (gzip, deflate, etc.)
|
||||
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
|
||||
|
||||
// 初始化multi handle用于异步请求
|
||||
multi_handle = curl_multi_init();
|
||||
if (!multi_handle) {
|
||||
throw std::runtime_error("Failed to initialize CURL multi handle");
|
||||
}
|
||||
}
|
||||
|
||||
~Impl() {
|
||||
// 清理异步请求
|
||||
cleanup_async();
|
||||
|
||||
if (multi_handle) {
|
||||
curl_multi_cleanup(multi_handle);
|
||||
}
|
||||
if (curl) {
|
||||
curl_easy_cleanup(curl);
|
||||
}
|
||||
}
|
||||
|
||||
void cleanup_async() {
|
||||
if (async_easy) {
|
||||
curl_multi_remove_handle(multi_handle, async_easy);
|
||||
curl_easy_cleanup(async_easy);
|
||||
async_easy = nullptr;
|
||||
}
|
||||
async_state = AsyncState::IDLE;
|
||||
async_response_body.clear();
|
||||
}
|
||||
|
||||
void setup_easy_handle(CURL* handle, const std::string& url) {
|
||||
curl_easy_setopt(handle, CURLOPT_URL, url.c_str());
|
||||
curl_easy_setopt(handle, CURLOPT_TIMEOUT, timeout);
|
||||
curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
curl_easy_setopt(handle, CURLOPT_USERAGENT, user_agent.c_str());
|
||||
|
||||
if (follow_redirects) {
|
||||
curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(handle, CURLOPT_MAXREDIRS, 10L);
|
||||
}
|
||||
|
||||
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
if (!cookie_file.empty()) {
|
||||
curl_easy_setopt(handle, CURLOPT_COOKIEFILE, cookie_file.c_str());
|
||||
curl_easy_setopt(handle, CURLOPT_COOKIEJAR, cookie_file.c_str());
|
||||
} else {
|
||||
curl_easy_setopt(handle, CURLOPT_COOKIEFILE, "");
|
||||
}
|
||||
|
||||
curl_easy_setopt(handle, CURLOPT_ACCEPT_ENCODING, "");
|
||||
}
|
||||
};
|
||||
|
||||
HttpClient::HttpClient() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
|
@ -297,4 +350,106 @@ void HttpClient::set_follow_redirects(bool follow) {
|
|||
|
||||
void HttpClient::enable_cookies(const std::string& cookie_file) {
|
||||
pImpl->cookie_file = cookie_file;
|
||||
}
|
||||
|
||||
// ==================== 异步请求实现 ====================
|
||||
|
||||
void HttpClient::start_async_fetch(const std::string& url) {
|
||||
// 如果有正在进行的请求,先取消
|
||||
if (pImpl->async_easy) {
|
||||
cancel_async();
|
||||
}
|
||||
|
||||
// 创建新的easy handle
|
||||
pImpl->async_easy = curl_easy_init();
|
||||
if (!pImpl->async_easy) {
|
||||
pImpl->async_state = AsyncState::FAILED;
|
||||
pImpl->async_result.error_message = "Failed to create CURL handle";
|
||||
return;
|
||||
}
|
||||
|
||||
// 配置请求
|
||||
pImpl->setup_easy_handle(pImpl->async_easy, url);
|
||||
|
||||
// 设置写回调
|
||||
pImpl->async_response_body.clear();
|
||||
curl_easy_setopt(pImpl->async_easy, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(pImpl->async_easy, CURLOPT_WRITEDATA, &pImpl->async_response_body);
|
||||
|
||||
// 添加到multi handle
|
||||
curl_multi_add_handle(pImpl->multi_handle, pImpl->async_easy);
|
||||
|
||||
pImpl->async_state = AsyncState::LOADING;
|
||||
pImpl->async_result = HttpResponse{}; // 重置结果
|
||||
}
|
||||
|
||||
AsyncState HttpClient::poll_async() {
|
||||
if (pImpl->async_state != AsyncState::LOADING) {
|
||||
return pImpl->async_state;
|
||||
}
|
||||
|
||||
// 执行非阻塞的multi perform
|
||||
int still_running = 0;
|
||||
CURLMcode mc = curl_multi_perform(pImpl->multi_handle, &still_running);
|
||||
|
||||
if (mc != CURLM_OK) {
|
||||
pImpl->async_result.error_message = curl_multi_strerror(mc);
|
||||
pImpl->async_state = AsyncState::FAILED;
|
||||
pImpl->cleanup_async();
|
||||
return pImpl->async_state;
|
||||
}
|
||||
|
||||
// 检查是否有完成的请求
|
||||
int msgs_left = 0;
|
||||
CURLMsg* msg;
|
||||
while ((msg = curl_multi_info_read(pImpl->multi_handle, &msgs_left))) {
|
||||
if (msg->msg == CURLMSG_DONE) {
|
||||
CURL* easy = msg->easy_handle;
|
||||
CURLcode result = msg->data.result;
|
||||
|
||||
if (result == CURLE_OK) {
|
||||
// 获取响应信息
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
pImpl->async_result.status_code = static_cast<int>(http_code);
|
||||
|
||||
char* content_type = nullptr;
|
||||
curl_easy_getinfo(easy, CURLINFO_CONTENT_TYPE, &content_type);
|
||||
if (content_type) {
|
||||
pImpl->async_result.content_type = content_type;
|
||||
}
|
||||
|
||||
pImpl->async_result.body = std::move(pImpl->async_response_body);
|
||||
pImpl->async_state = AsyncState::COMPLETE;
|
||||
} else {
|
||||
pImpl->async_result.error_message = curl_easy_strerror(result);
|
||||
pImpl->async_state = AsyncState::FAILED;
|
||||
}
|
||||
|
||||
// 清理handle但保留状态供获取结果
|
||||
curl_multi_remove_handle(pImpl->multi_handle, pImpl->async_easy);
|
||||
curl_easy_cleanup(pImpl->async_easy);
|
||||
pImpl->async_easy = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
return pImpl->async_state;
|
||||
}
|
||||
|
||||
HttpResponse HttpClient::get_async_result() {
|
||||
HttpResponse result = std::move(pImpl->async_result);
|
||||
pImpl->async_result = HttpResponse{};
|
||||
pImpl->async_state = AsyncState::IDLE;
|
||||
return result;
|
||||
}
|
||||
|
||||
void HttpClient::cancel_async() {
|
||||
if (pImpl->async_easy) {
|
||||
pImpl->cleanup_async();
|
||||
pImpl->async_state = AsyncState::CANCELLED;
|
||||
}
|
||||
}
|
||||
|
||||
bool HttpClient::is_async_active() const {
|
||||
return pImpl->async_state == AsyncState::LOADING;
|
||||
}
|
||||
|
|
@ -5,6 +5,15 @@
|
|||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
// 异步请求状态
|
||||
enum class AsyncState {
|
||||
IDLE, // 无活跃请求
|
||||
LOADING, // 请求进行中
|
||||
COMPLETE, // 请求成功完成
|
||||
FAILED, // 请求失败
|
||||
CANCELLED // 请求被取消
|
||||
};
|
||||
|
||||
struct HttpResponse {
|
||||
int status_code;
|
||||
std::string body;
|
||||
|
|
@ -36,10 +45,20 @@ public:
|
|||
HttpClient();
|
||||
~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 start_async_fetch(const std::string& url);
|
||||
AsyncState poll_async(); // 非阻塞轮询,返回当前状态
|
||||
HttpResponse get_async_result(); // 获取结果并重置状态
|
||||
void cancel_async(); // 取消当前异步请求
|
||||
bool is_async_active() const; // 是否有活跃的异步请求
|
||||
|
||||
// 配置
|
||||
void set_timeout(long timeout_seconds);
|
||||
void set_user_agent(const std::string& user_agent);
|
||||
void set_follow_redirects(bool follow);
|
||||
|
|
|
|||
|
|
@ -174,6 +174,12 @@ public:
|
|||
case '?':
|
||||
result.action = Action::HELP;
|
||||
break;
|
||||
case 'B':
|
||||
result.action = Action::ADD_BOOKMARK;
|
||||
break;
|
||||
case 'D':
|
||||
result.action = Action::REMOVE_BOOKMARK;
|
||||
break;
|
||||
default:
|
||||
buffer.clear();
|
||||
break;
|
||||
|
|
@ -201,6 +207,10 @@ public:
|
|||
result.action = Action::OPEN_URL;
|
||||
result.text = command.substr(space_pos + 1);
|
||||
}
|
||||
} else if (command == "bookmarks" || command == "bm" || command == "b") {
|
||||
result.action = Action::SHOW_BOOKMARKS;
|
||||
} else if (command == "history" || command == "hist" || command == "hi") {
|
||||
result.action = Action::SHOW_HISTORY;
|
||||
} else if (!command.empty() && std::isdigit(command[0])) {
|
||||
try {
|
||||
result.action = Action::GOTO_LINE;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@ enum class Action {
|
|||
QUIT,
|
||||
HELP,
|
||||
SET_MARK, // Set a mark (m + letter)
|
||||
GOTO_MARK // Jump to mark (' + letter)
|
||||
GOTO_MARK, // Jump to mark (' + letter)
|
||||
ADD_BOOKMARK, // Add current page to bookmarks (B)
|
||||
REMOVE_BOOKMARK, // Remove current page from bookmarks (D)
|
||||
SHOW_BOOKMARKS, // Show bookmarks page (:bookmarks)
|
||||
SHOW_HISTORY // Show history page (:history)
|
||||
};
|
||||
|
||||
struct InputResult {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
void print_usage(const char* prog_name) {
|
||||
std::cout << "TUT - Terminal User Interface Browser\n"
|
||||
<< "A vim-style terminal web browser for comfortable reading\n\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"
|
||||
|
|
@ -44,4 +44,3 @@ int main(int argc, char* argv[]) {
|
|||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
#include "browser_v2.h"
|
||||
#include <iostream>
|
||||
#include <cstring>
|
||||
|
||||
void print_usage(const char* prog_name) {
|
||||
std::cout << "TUT 2.0 - Terminal User Interface Browser\n"
|
||||
<< "A vim-style terminal web browser with True Color support\n\n"
|
||||
<< "Usage: " << prog_name << " [URL]\n\n"
|
||||
<< "If no URL is provided, the browser will start with a help page.\n\n"
|
||||
<< "Examples:\n"
|
||||
<< " " << prog_name << "\n"
|
||||
<< " " << prog_name << " https://example.com\n"
|
||||
<< " " << prog_name << " https://news.ycombinator.com\n\n"
|
||||
<< "Vim-style keybindings:\n"
|
||||
<< " j/k - Scroll down/up\n"
|
||||
<< " gg/G - Go to top/bottom\n"
|
||||
<< " / - Search\n"
|
||||
<< " Tab - Next link\n"
|
||||
<< " Enter - Follow link\n"
|
||||
<< " h/l - Back/Forward\n"
|
||||
<< " :o URL - Open URL\n"
|
||||
<< " :q - Quit\n"
|
||||
<< " ? - Show help\n\n"
|
||||
<< "New in 2.0:\n"
|
||||
<< " - True Color (24-bit) support\n"
|
||||
<< " - Improved Unicode handling\n"
|
||||
<< " - Differential rendering for better performance\n";
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
std::string initial_url;
|
||||
|
||||
if (argc > 1) {
|
||||
if (std::strcmp(argv[1], "-h") == 0 || std::strcmp(argv[1], "--help") == 0) {
|
||||
print_usage(argv[0]);
|
||||
return 0;
|
||||
}
|
||||
initial_url = argv[1];
|
||||
}
|
||||
|
||||
try {
|
||||
BrowserV2 browser;
|
||||
browser.run(initial_url);
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Error: " << e.what() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -77,20 +77,6 @@ void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<La
|
|||
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 ||
|
||||
|
|
@ -106,10 +92,86 @@ void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<La
|
|||
return;
|
||||
}
|
||||
|
||||
// 处理块级元素
|
||||
if (node->is_block_element()) {
|
||||
layout_block_element(node, ctx, blocks);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理链接 - 当链接单独出现时(不在段落内),创建一个单独的块
|
||||
if (node->element_type == ElementType::LINK && node->link_index >= 0) {
|
||||
// 检查链接是否有可见文本
|
||||
std::string link_text = node->get_all_text();
|
||||
// 去除空白
|
||||
size_t start = link_text.find_first_not_of(" \t\n\r");
|
||||
size_t end = link_text.find_last_not_of(" \t\n\r");
|
||||
if (start != std::string::npos && end != std::string::npos) {
|
||||
link_text = link_text.substr(start, end - start + 1);
|
||||
} else {
|
||||
link_text = "";
|
||||
}
|
||||
|
||||
if (!link_text.empty()) {
|
||||
LayoutBlock block;
|
||||
block.type = ElementType::PARAGRAPH;
|
||||
block.margin_top = 0;
|
||||
block.margin_bottom = 0;
|
||||
|
||||
LayoutLine line;
|
||||
line.indent = MARGIN_LEFT;
|
||||
|
||||
StyledSpan span;
|
||||
span.text = link_text;
|
||||
span.fg = colors::LINK_FG;
|
||||
span.attrs = ATTR_UNDERLINE;
|
||||
span.link_index = node->link_index;
|
||||
line.spans.push_back(span);
|
||||
|
||||
block.lines.push_back(line);
|
||||
blocks.push_back(block);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理容器元素 - 递归处理子节点
|
||||
// 这包括:html, body, div, table, span, center 等所有容器类元素
|
||||
if (node->node_type == NodeType::ELEMENT && !node->children.empty()) {
|
||||
for (const auto& child : node->children) {
|
||||
layout_node(child.get(), ctx, blocks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理独立文本节点
|
||||
if (node->node_type == NodeType::TEXT && !node->text_content.empty()) {
|
||||
std::string text = node->text_content;
|
||||
// 去除首尾空白
|
||||
size_t start = text.find_first_not_of(" \t\n\r");
|
||||
size_t end = text.find_last_not_of(" \t\n\r");
|
||||
if (start != std::string::npos && end != std::string::npos) {
|
||||
text = text.substr(start, end - start + 1);
|
||||
} else {
|
||||
return; // 空白文本,跳过
|
||||
}
|
||||
|
||||
if (!text.empty()) {
|
||||
LayoutBlock block;
|
||||
block.type = ElementType::TEXT;
|
||||
block.margin_top = 0;
|
||||
block.margin_bottom = 0;
|
||||
|
||||
std::vector<StyledSpan> spans;
|
||||
StyledSpan span;
|
||||
span.text = text;
|
||||
span.fg = colors::FG_PRIMARY;
|
||||
spans.push_back(span);
|
||||
|
||||
block.lines = wrap_text(spans, content_width_, MARGIN_LEFT);
|
||||
if (!block.lines.empty()) {
|
||||
blocks.push_back(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 内联元素在块级元素内部处理
|
||||
}
|
||||
|
||||
void LayoutEngine::layout_block_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks) {
|
||||
|
|
@ -391,10 +453,88 @@ void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, s
|
|||
block.margin_top = 0;
|
||||
block.margin_bottom = 1;
|
||||
|
||||
// 检查是否有解码后的图片数据
|
||||
if (node->image_data.is_valid()) {
|
||||
// 渲染 ASCII Art
|
||||
ImageRenderer renderer;
|
||||
renderer.set_mode(ImageRenderer::Mode::BLOCKS);
|
||||
renderer.set_color_enabled(true);
|
||||
|
||||
// 计算图片最大尺寸(留出左边距)
|
||||
int max_width = content_width_;
|
||||
int max_height = 30; // 限制高度
|
||||
|
||||
// 如果节点指定了尺寸,使用更小的值
|
||||
if (node->img_width > 0) {
|
||||
max_width = std::min(max_width, node->img_width);
|
||||
}
|
||||
if (node->img_height > 0) {
|
||||
max_height = std::min(max_height, node->img_height / 2); // 考虑字符高宽比
|
||||
}
|
||||
|
||||
AsciiImage ascii = renderer.render(node->image_data, max_width, max_height);
|
||||
|
||||
if (!ascii.lines.empty()) {
|
||||
for (size_t i = 0; i < ascii.lines.size(); ++i) {
|
||||
LayoutLine line;
|
||||
line.indent = MARGIN_LEFT;
|
||||
|
||||
// 将每一行作为一个 span
|
||||
// 但由于颜色可能不同,需要逐字符处理
|
||||
const std::string& line_text = ascii.lines[i];
|
||||
const std::vector<uint32_t>& line_colors = ascii.colors[i];
|
||||
|
||||
// 为了效率,尝试合并相同颜色的字符
|
||||
size_t pos = 0;
|
||||
while (pos < line_text.size()) {
|
||||
// 获取当前字符的字节数(UTF-8)
|
||||
int char_bytes = 1;
|
||||
unsigned char c = line_text[pos];
|
||||
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;
|
||||
}
|
||||
|
||||
// 获取颜色索引(基于显示宽度位置)
|
||||
size_t color_idx = 0;
|
||||
for (size_t j = 0; j < pos; ) {
|
||||
unsigned char ch = line_text[j];
|
||||
int bytes = 1;
|
||||
if ((ch & 0x80) == 0) bytes = 1;
|
||||
else if ((ch & 0xE0) == 0xC0) bytes = 2;
|
||||
else if ((ch & 0xF0) == 0xE0) bytes = 3;
|
||||
else if ((ch & 0xF8) == 0xF0) bytes = 4;
|
||||
color_idx++;
|
||||
j += bytes;
|
||||
}
|
||||
|
||||
uint32_t color = (color_idx < line_colors.size()) ? line_colors[color_idx] : colors::FG_PRIMARY;
|
||||
|
||||
StyledSpan span;
|
||||
span.text = line_text.substr(pos, char_bytes);
|
||||
span.fg = color;
|
||||
span.attrs = ATTR_NONE;
|
||||
line.spans.push_back(span);
|
||||
|
||||
pos += char_bytes;
|
||||
}
|
||||
|
||||
block.lines.push_back(line);
|
||||
}
|
||||
blocks.push_back(block);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到占位符
|
||||
LayoutLine line;
|
||||
line.indent = MARGIN_LEFT;
|
||||
|
||||
// 生成图片占位符
|
||||
std::string placeholder = make_image_placeholder(node->alt_text, node->img_src);
|
||||
|
||||
StyledSpan span;
|
||||
|
|
@ -407,6 +547,41 @@ void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, s
|
|||
blocks.push_back(block);
|
||||
}
|
||||
|
||||
// 辅助函数:检查是否需要在两个文本之间添加空格
|
||||
static bool needs_space_between(const std::string& prev, const std::string& next) {
|
||||
if (prev.empty() || next.empty()) return false;
|
||||
|
||||
char last_char = prev.back();
|
||||
char first_char = next.front();
|
||||
|
||||
// 检查前一个是否以空白结尾
|
||||
bool prev_ends_with_space = (last_char == ' ' || last_char == '\t' ||
|
||||
last_char == '\n' || last_char == '\r');
|
||||
if (prev_ends_with_space) return false;
|
||||
|
||||
// 检查当前是否以空白开头
|
||||
bool curr_starts_with_space = (first_char == ' ' || first_char == '\t' ||
|
||||
first_char == '\n' || first_char == '\r');
|
||||
if (curr_starts_with_space) return false;
|
||||
|
||||
// 检查是否是标点符号(不需要空格)
|
||||
bool is_punct = (first_char == '.' || first_char == ',' ||
|
||||
first_char == '!' || first_char == '?' ||
|
||||
first_char == ':' || first_char == ';' ||
|
||||
first_char == ')' || first_char == ']' ||
|
||||
first_char == '}' || first_char == '|' ||
|
||||
first_char == '\'' || first_char == '"');
|
||||
if (is_punct) return false;
|
||||
|
||||
// 检查前一个字符是否是特殊符号(不需要空格)
|
||||
bool prev_is_open = (last_char == '(' || last_char == '[' ||
|
||||
last_char == '{' || last_char == '\'' ||
|
||||
last_char == '"');
|
||||
if (prev_is_open) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
|
||||
if (!node) return;
|
||||
|
||||
|
|
@ -424,10 +599,21 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
|
|||
int link_idx = node->link_index;
|
||||
|
||||
// 递归处理子节点
|
||||
for (const auto& child : node->children) {
|
||||
for (size_t i = 0; i < node->children.size(); ++i) {
|
||||
const auto& child = node->children[i];
|
||||
|
||||
if (child->node_type == NodeType::TEXT) {
|
||||
std::string text = child->text_content;
|
||||
|
||||
// 检查是否需要在之前的内容和当前内容之间添加空格
|
||||
if (!spans.empty() && !text.empty()) {
|
||||
if (needs_space_between(spans.back().text, text)) {
|
||||
spans.back().text += " ";
|
||||
}
|
||||
}
|
||||
|
||||
StyledSpan span;
|
||||
span.text = child->text_content;
|
||||
span.text = text;
|
||||
span.fg = fg;
|
||||
span.attrs = attrs;
|
||||
span.link_index = link_idx;
|
||||
|
|
@ -438,6 +624,16 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
|
|||
|
||||
spans.push_back(span);
|
||||
} else if (!child->is_block_element()) {
|
||||
// 获取子节点的全部文本,用于检查是否需要空格
|
||||
std::string child_text = child->get_all_text();
|
||||
|
||||
// 在递归调用前检查空格
|
||||
if (!spans.empty() && !child_text.empty()) {
|
||||
if (needs_space_between(spans.back().text, child_text)) {
|
||||
spans.back().text += " ";
|
||||
}
|
||||
}
|
||||
|
||||
collect_inline_content(child.get(), ctx, spans);
|
||||
}
|
||||
}
|
||||
|
|
@ -447,8 +643,17 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
|
|||
void LayoutEngine::layout_text(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
|
||||
if (!node || node->text_content.empty()) return;
|
||||
|
||||
std::string text = node->text_content;
|
||||
|
||||
// 检查是否需要在之前的内容和当前内容之间添加空格
|
||||
if (!spans.empty() && !text.empty()) {
|
||||
if (needs_space_between(spans.back().text, text)) {
|
||||
spans.back().text += " ";
|
||||
}
|
||||
}
|
||||
|
||||
StyledSpan span;
|
||||
span.text = node->text_content;
|
||||
span.text = text;
|
||||
span.fg = colors::FG_PRIMARY;
|
||||
|
||||
if (ctx.in_blockquote) {
|
||||
|
|
@ -468,12 +673,13 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
|
|||
LayoutLine current_line;
|
||||
current_line.indent = indent;
|
||||
size_t current_width = 0;
|
||||
bool is_line_start = true; // 整行的开始标记
|
||||
|
||||
for (const auto& span : spans) {
|
||||
// 分词处理
|
||||
std::istringstream iss(span.text);
|
||||
std::string word;
|
||||
bool first_word = true;
|
||||
bool first_word_in_span = true;
|
||||
|
||||
while (iss >> word) {
|
||||
size_t word_width = Unicode::display_width(word);
|
||||
|
|
@ -487,12 +693,20 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
|
|||
current_line = LayoutLine();
|
||||
current_line.indent = indent;
|
||||
current_width = 0;
|
||||
first_word = true;
|
||||
is_line_start = true;
|
||||
}
|
||||
|
||||
// 添加空格(如果不是行首)
|
||||
if (current_width > 0 && !first_word) {
|
||||
if (!current_line.spans.empty()) {
|
||||
// 添加空格(如果不是行首且不是第一个单词)
|
||||
// 需要在不同 span 之间也添加空格
|
||||
if (!is_line_start) {
|
||||
// 检查是否需要空格(避免在标点前加空格)
|
||||
char first_char = word.front();
|
||||
bool is_punct = (first_char == '.' || first_char == ',' ||
|
||||
first_char == '!' || first_char == '?' ||
|
||||
first_char == ':' || first_char == ';' ||
|
||||
first_char == ')' || first_char == ']' ||
|
||||
first_char == '}' || first_char == '|');
|
||||
if (!is_punct && !current_line.spans.empty()) {
|
||||
current_line.spans.back().text += " ";
|
||||
current_width += 1;
|
||||
}
|
||||
|
|
@ -503,7 +717,8 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
|
|||
word_span.text = word;
|
||||
current_line.spans.push_back(word_span);
|
||||
current_width += word_width;
|
||||
first_word = false;
|
||||
is_line_start = false;
|
||||
first_word_in_span = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,641 +0,0 @@
|
|||
#include "text_renderer.h"
|
||||
#include "dom_tree.h"
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <cstring>
|
||||
#include <cwchar>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
namespace {
|
||||
// Calculate display width of UTF-8 string (handling CJK characters)
|
||||
size_t display_width(const std::string& str) {
|
||||
size_t width = 0;
|
||||
for (size_t i = 0; i < str.length(); ) {
|
||||
unsigned char c = str[i];
|
||||
|
||||
if (c < 0x80) {
|
||||
// ASCII
|
||||
width += 1;
|
||||
i += 1;
|
||||
} else if ((c & 0xE0) == 0xC0) {
|
||||
// 2-byte UTF-8
|
||||
width += 1;
|
||||
i += 2;
|
||||
} else if ((c & 0xF0) == 0xE0) {
|
||||
// 3-byte UTF-8 (likely CJK)
|
||||
width += 2;
|
||||
i += 3;
|
||||
} else if ((c & 0xF8) == 0xF0) {
|
||||
// 4-byte UTF-8
|
||||
width += 2;
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
// Pad string to specific visual width
|
||||
std::string pad_string(const std::string& str, size_t target_width) {
|
||||
size_t current_width = display_width(str);
|
||||
if (current_width >= target_width) return str;
|
||||
return str + std::string(target_width - current_width, ' ');
|
||||
}
|
||||
|
||||
// Clean whitespace
|
||||
std::string clean_text(const std::string& text) {
|
||||
std::string result;
|
||||
bool in_space = false;
|
||||
|
||||
for (char c : text) {
|
||||
if (std::isspace(c)) {
|
||||
if (!in_space) {
|
||||
result += ' ';
|
||||
in_space = true;
|
||||
}
|
||||
} else {
|
||||
result += c;
|
||||
in_space = false;
|
||||
}
|
||||
}
|
||||
|
||||
size_t start = result.find_first_not_of(" \t\n\r");
|
||||
if (start == std::string::npos) return "";
|
||||
|
||||
size_t end = result.find_last_not_of(" \t\n\r");
|
||||
return result.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
struct LinkInfo {
|
||||
size_t start_pos;
|
||||
size_t end_pos;
|
||||
int link_index;
|
||||
int field_index;
|
||||
};
|
||||
|
||||
// Text wrapping with link preservation
|
||||
std::vector<std::pair<std::string, std::vector<LinkInfo>>> wrap_text_with_links(
|
||||
const std::string& text,
|
||||
int max_width,
|
||||
const std::vector<InlineLink>& links
|
||||
) {
|
||||
std::vector<std::pair<std::string, std::vector<LinkInfo>>> result;
|
||||
if (max_width <= 0) return result;
|
||||
|
||||
// 1. Insert [N] markers for links (form fields don't get [N])
|
||||
std::string marked_text;
|
||||
std::vector<LinkInfo> adjusted_links;
|
||||
size_t pos = 0;
|
||||
|
||||
for (const auto& link : links) {
|
||||
marked_text += text.substr(pos, link.start_pos - pos);
|
||||
size_t link_start = marked_text.length();
|
||||
|
||||
marked_text += text.substr(link.start_pos, link.end_pos - link.start_pos);
|
||||
|
||||
// Add marker [N] only for links
|
||||
if (link.link_index >= 0) {
|
||||
std::string marker = "[" + std::to_string(link.link_index + 1) + "]";
|
||||
marked_text += marker;
|
||||
}
|
||||
|
||||
size_t link_end = marked_text.length();
|
||||
|
||||
adjusted_links.push_back({link_start, link_end, link.link_index, link.field_index});
|
||||
pos = link.end_pos;
|
||||
}
|
||||
|
||||
if (pos < text.length()) {
|
||||
marked_text += text.substr(pos);
|
||||
}
|
||||
|
||||
// 2. Wrap text
|
||||
size_t line_start_idx = 0;
|
||||
size_t current_line_width = 0;
|
||||
size_t last_space_idx = std::string::npos;
|
||||
|
||||
for (size_t i = 0; i <= marked_text.length(); ++i) {
|
||||
bool is_break = (i == marked_text.length() || marked_text[i] == ' ' || marked_text[i] == '\n');
|
||||
|
||||
if (is_break) {
|
||||
std::string word = marked_text.substr(
|
||||
(last_space_idx == std::string::npos) ? line_start_idx : last_space_idx + 1,
|
||||
i - ((last_space_idx == std::string::npos) ? line_start_idx : last_space_idx + 1)
|
||||
);
|
||||
|
||||
size_t word_width = display_width(word);
|
||||
size_t space_width = (current_line_width == 0) ? 0 : 1;
|
||||
|
||||
if (current_line_width + space_width + word_width > static_cast<size_t>(max_width)) {
|
||||
// Wrap
|
||||
if (current_line_width > 0) {
|
||||
// End current line at last space
|
||||
std::string line_str = marked_text.substr(line_start_idx, last_space_idx - line_start_idx);
|
||||
|
||||
// Collect links
|
||||
std::vector<LinkInfo> line_links;
|
||||
for (const auto& link : adjusted_links) {
|
||||
// Check overlap
|
||||
size_t link_s = link.start_pos;
|
||||
size_t link_e = link.end_pos;
|
||||
size_t line_s = line_start_idx;
|
||||
size_t line_e = last_space_idx;
|
||||
|
||||
if (link_s < line_e && link_e > line_s) {
|
||||
size_t start = (link_s > line_s) ? link_s - line_s : 0;
|
||||
size_t end = (link_e < line_e) ? link_e - line_s : line_e - line_s;
|
||||
line_links.push_back({start, end, link.link_index, link.field_index});
|
||||
}
|
||||
}
|
||||
result.push_back({line_str, line_links});
|
||||
|
||||
// Start new line
|
||||
line_start_idx = last_space_idx + 1;
|
||||
current_line_width = word_width;
|
||||
last_space_idx = i;
|
||||
} else {
|
||||
// Word itself is too long, force break (not implemented for simplicity, just overflow)
|
||||
last_space_idx = i;
|
||||
current_line_width += space_width + word_width;
|
||||
}
|
||||
} else {
|
||||
current_line_width += space_width + word_width;
|
||||
last_space_idx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last line
|
||||
if (line_start_idx < marked_text.length()) {
|
||||
std::string line_str = marked_text.substr(line_start_idx);
|
||||
std::vector<LinkInfo> line_links;
|
||||
for (const auto& link : adjusted_links) {
|
||||
size_t link_s = link.start_pos;
|
||||
size_t link_e = link.end_pos;
|
||||
size_t line_s = line_start_idx;
|
||||
size_t line_e = marked_text.length();
|
||||
|
||||
if (link_s < line_e && link_e > line_s) {
|
||||
size_t start = (link_s > line_s) ? link_s - line_s : 0;
|
||||
size_t end = (link_e < line_e) ? link_e - line_s : line_e - line_s;
|
||||
line_links.push_back({start, end, link.link_index, link.field_index});
|
||||
}
|
||||
}
|
||||
result.push_back({line_str, line_links});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TextRenderer::Impl
|
||||
// ============================================================================
|
||||
|
||||
class TextRenderer::Impl {
|
||||
public:
|
||||
RenderConfig config;
|
||||
|
||||
struct InlineContent {
|
||||
std::string text;
|
||||
std::vector<InlineLink> links;
|
||||
};
|
||||
|
||||
RenderedLine create_empty_line() {
|
||||
RenderedLine line;
|
||||
line.text = "";
|
||||
line.color_pair = COLOR_NORMAL;
|
||||
line.is_bold = false;
|
||||
line.is_link = false;
|
||||
line.link_index = -1;
|
||||
return line;
|
||||
}
|
||||
|
||||
std::vector<RenderedLine> render_tree(const DocumentTree& tree, int screen_width) {
|
||||
std::vector<RenderedLine> lines;
|
||||
if (!tree.root) return lines;
|
||||
|
||||
RenderContext ctx;
|
||||
ctx.screen_width = config.center_content ? std::min(config.max_width, screen_width) : screen_width;
|
||||
ctx.current_indent = 0;
|
||||
ctx.nesting_level = 0;
|
||||
ctx.color_pair = COLOR_NORMAL;
|
||||
ctx.is_bold = false;
|
||||
|
||||
render_node(tree.root.get(), ctx, lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
void render_node(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
if (!node || !node->should_render()) return;
|
||||
|
||||
if (node->is_block_element()) {
|
||||
if (node->tag_name == "table") {
|
||||
render_table(node, ctx, lines);
|
||||
} else {
|
||||
switch (node->element_type) {
|
||||
case ElementType::HEADING1:
|
||||
case ElementType::HEADING2:
|
||||
case ElementType::HEADING3:
|
||||
render_heading(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::PARAGRAPH:
|
||||
render_paragraph(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::HORIZONTAL_RULE:
|
||||
render_hr(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::CODE_BLOCK:
|
||||
render_code_block(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::BLOCKQUOTE:
|
||||
render_blockquote(node, ctx, lines);
|
||||
break;
|
||||
default:
|
||||
if (node->tag_name == "ul" || node->tag_name == "ol") {
|
||||
render_list(node, ctx, lines);
|
||||
} else {
|
||||
for (auto& child : node->children) {
|
||||
render_node(child.get(), ctx, lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node->node_type == NodeType::DOCUMENT || node->node_type == NodeType::ELEMENT) {
|
||||
for (auto& child : node->children) {
|
||||
render_node(child.get(), ctx, lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Table Rendering
|
||||
// ========================================================================
|
||||
|
||||
struct CellData {
|
||||
std::vector<std::string> lines; // Wrapped lines
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int colspan = 1;
|
||||
int rowspan = 1;
|
||||
bool is_header = false;
|
||||
};
|
||||
|
||||
void render_table(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
// Simplified table rendering (skipping complex grid for brevity, reverting to previous improved logic)
|
||||
// Note: For brevity in this tool call, reusing the logic from previous step but integrated with form fields?
|
||||
// Actually, let's keep the logic I wrote before.
|
||||
|
||||
// 1. Collect Table Data
|
||||
std::vector<std::vector<CellData>> grid;
|
||||
std::vector<int> col_widths;
|
||||
int max_cols = 0;
|
||||
|
||||
for (auto& child : node->children) {
|
||||
if (child->tag_name == "tr") {
|
||||
std::vector<CellData> row;
|
||||
for (auto& cell : child->children) {
|
||||
if (cell->tag_name == "td" || cell->tag_name == "th") {
|
||||
CellData data;
|
||||
data.is_header = (cell->tag_name == "th");
|
||||
data.colspan = cell->colspan > 0 ? cell->colspan : 1;
|
||||
InlineContent content = collect_inline_content(cell.get());
|
||||
std::string clean = clean_text(content.text);
|
||||
data.lines.push_back(clean);
|
||||
data.width = display_width(clean);
|
||||
data.height = 1;
|
||||
row.push_back(data);
|
||||
}
|
||||
}
|
||||
if (!row.empty()) {
|
||||
grid.push_back(row);
|
||||
max_cols = std::max(max_cols, (int)row.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (grid.empty()) return;
|
||||
|
||||
col_widths.assign(max_cols, 0);
|
||||
for (const auto& row : grid) {
|
||||
for (size_t i = 0; i < row.size(); ++i) {
|
||||
if (i < col_widths.size()) {
|
||||
col_widths[i] = std::max(col_widths[i], row[i].width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int total_width = std::accumulate(col_widths.begin(), col_widths.end(), 0);
|
||||
int available_width = ctx.screen_width - 4;
|
||||
available_width = std::max(10, available_width);
|
||||
|
||||
if (total_width > available_width) {
|
||||
double ratio = (double)available_width / total_width;
|
||||
for (auto& w : col_widths) {
|
||||
w = std::max(3, (int)(w * ratio));
|
||||
}
|
||||
}
|
||||
|
||||
std::string border_line = "+";
|
||||
for (int w : col_widths) {
|
||||
border_line += std::string(w + 2, '-') + "+";
|
||||
}
|
||||
|
||||
RenderedLine border;
|
||||
border.text = border_line;
|
||||
border.color_pair = COLOR_DIM;
|
||||
lines.push_back(border);
|
||||
|
||||
for (auto& row : grid) {
|
||||
int max_row_height = 0;
|
||||
std::vector<std::vector<std::string>> row_wrapped_content;
|
||||
|
||||
for (size_t i = 0; i < row.size(); ++i) {
|
||||
if (i >= col_widths.size()) break;
|
||||
|
||||
int cell_w = col_widths[i];
|
||||
std::string raw_text = row[i].lines[0];
|
||||
auto wrapped = wrap_text_with_links(raw_text, cell_w, {}); // Simplified: no links in table for now
|
||||
|
||||
std::vector<std::string> cell_lines;
|
||||
for(auto& p : wrapped) cell_lines.push_back(p.first);
|
||||
if (cell_lines.empty()) cell_lines.push_back("");
|
||||
|
||||
row_wrapped_content.push_back(cell_lines);
|
||||
max_row_height = std::max(max_row_height, (int)cell_lines.size());
|
||||
}
|
||||
|
||||
for (int h = 0; h < max_row_height; ++h) {
|
||||
std::string line_str = "|";
|
||||
for (size_t i = 0; i < col_widths.size(); ++i) {
|
||||
int w = col_widths[i];
|
||||
std::string content = "";
|
||||
if (i < row_wrapped_content.size() && h < (int)row_wrapped_content[i].size()) {
|
||||
content = row_wrapped_content[i][h];
|
||||
}
|
||||
line_str += " " + pad_string(content, w) + " |";
|
||||
}
|
||||
|
||||
RenderedLine rline;
|
||||
rline.text = line_str;
|
||||
rline.color_pair = COLOR_NORMAL;
|
||||
lines.push_back(rline);
|
||||
}
|
||||
|
||||
lines.push_back(border);
|
||||
}
|
||||
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Other Elements
|
||||
// ========================================================================
|
||||
|
||||
void render_heading(DomNode* node, RenderContext& /*ctx*/, std::vector<RenderedLine>& lines) {
|
||||
InlineContent content = collect_inline_content(node);
|
||||
if (content.text.empty()) return;
|
||||
|
||||
RenderedLine line;
|
||||
line.text = clean_text(content.text);
|
||||
line.color_pair = COLOR_HEADING1;
|
||||
line.is_bold = true;
|
||||
lines.push_back(line);
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_paragraph(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
InlineContent content = collect_inline_content(node);
|
||||
std::string text = clean_text(content.text);
|
||||
if (text.empty()) return;
|
||||
|
||||
auto wrapped = wrap_text_with_links(text, ctx.screen_width, content.links);
|
||||
for (const auto& [line_text, link_infos] : wrapped) {
|
||||
RenderedLine line;
|
||||
line.text = line_text;
|
||||
line.color_pair = COLOR_NORMAL;
|
||||
if (!link_infos.empty()) {
|
||||
line.is_link = true; // Kept for compatibility, though we use interactive_ranges
|
||||
line.link_index = -1;
|
||||
|
||||
for (const auto& li : link_infos) {
|
||||
InteractiveRange range;
|
||||
range.start = li.start_pos;
|
||||
range.end = li.end_pos;
|
||||
range.link_index = li.link_index;
|
||||
range.field_index = li.field_index;
|
||||
line.interactive_ranges.push_back(range);
|
||||
|
||||
if (li.link_index >= 0) line.link_index = li.link_index; // Heuristic: set main link index to first link
|
||||
}
|
||||
}
|
||||
lines.push_back(line);
|
||||
}
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_list(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
bool is_ordered = (node->tag_name == "ol");
|
||||
int count = 1;
|
||||
|
||||
for(auto& child : node->children) {
|
||||
if(child->tag_name == "li") {
|
||||
InlineContent content = collect_inline_content(child.get());
|
||||
std::string prefix = is_ordered ? std::to_string(count++) + ". " : "* ";
|
||||
|
||||
auto wrapped = wrap_text_with_links(clean_text(content.text), ctx.screen_width - 4, content.links);
|
||||
|
||||
bool first = true;
|
||||
for(const auto& [txt, links_info] : wrapped) {
|
||||
RenderedLine line;
|
||||
line.text = (first ? prefix : " ") + txt;
|
||||
line.color_pair = COLOR_NORMAL;
|
||||
|
||||
if(!links_info.empty()) {
|
||||
line.is_link = true;
|
||||
for(const auto& l : links_info) {
|
||||
InteractiveRange range;
|
||||
range.start = l.start_pos + prefix.length();
|
||||
range.end = l.end_pos + prefix.length();
|
||||
range.link_index = l.link_index;
|
||||
range.field_index = l.field_index;
|
||||
line.interactive_ranges.push_back(range);
|
||||
}
|
||||
}
|
||||
lines.push_back(line);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_hr(DomNode* /*node*/, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
RenderedLine line;
|
||||
line.text = std::string(ctx.screen_width, '-');
|
||||
line.color_pair = COLOR_DIM;
|
||||
lines.push_back(line);
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_code_block(DomNode* node, RenderContext& /*ctx*/, std::vector<RenderedLine>& lines) {
|
||||
std::string text = node->get_all_text();
|
||||
std::istringstream iss(text);
|
||||
std::string line_str;
|
||||
while(std::getline(iss, line_str)) {
|
||||
RenderedLine line;
|
||||
line.text = " " + line_str;
|
||||
line.color_pair = COLOR_DIM;
|
||||
lines.push_back(line);
|
||||
}
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_blockquote(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
for (auto& child : node->children) {
|
||||
render_node(child.get(), ctx, lines);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Collect Inline Content
|
||||
InlineContent collect_inline_content(DomNode* node) {
|
||||
InlineContent result;
|
||||
for (auto& child : node->children) {
|
||||
if (child->node_type == NodeType::TEXT) {
|
||||
result.text += child->text_content;
|
||||
} else if (child->element_type == ElementType::LINK && child->link_index >= 0) {
|
||||
InlineLink link;
|
||||
link.text = child->get_all_text();
|
||||
link.url = child->href;
|
||||
link.link_index = child->link_index;
|
||||
link.field_index = -1;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += link.text;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::INPUT) {
|
||||
std::string repr;
|
||||
if (child->input_type == "checkbox") {
|
||||
repr = child->checked ? "[x]" : "[ ]";
|
||||
} else if (child->input_type == "radio") {
|
||||
repr = child->checked ? "(*)" : "( )";
|
||||
} else if (child->input_type == "submit" || child->input_type == "button") {
|
||||
repr = "[" + (child->value.empty() ? "Submit" : child->value) + "]";
|
||||
} else {
|
||||
// text, password, etc.
|
||||
std::string val = child->value.empty() ? child->placeholder : child->value;
|
||||
if (val.empty()) val = "________";
|
||||
repr = "[" + val + "]";
|
||||
}
|
||||
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::BUTTON) {
|
||||
std::string repr = "[" + (child->value.empty() ? (child->name.empty() ? "Button" : child->name) : child->value) + "]";
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::TEXTAREA) {
|
||||
std::string repr = "[ " + (child->value.empty() ? "Textarea" : child->value) + " ]";
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::SELECT) {
|
||||
std::string repr = "[ Select ]"; // Simplified
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::IMAGE) {
|
||||
// Render image placeholder
|
||||
std::string repr = "[IMG";
|
||||
if (!child->alt_text.empty()) {
|
||||
repr += ": " + child->alt_text;
|
||||
}
|
||||
repr += "]";
|
||||
|
||||
result.text += repr;
|
||||
// Images are not necessarily links unless wrapped in <a>.
|
||||
// If wrapped in <a>, the parent processing handles the link range.
|
||||
} else {
|
||||
InlineContent nested = collect_inline_content(child.get());
|
||||
size_t offset = result.text.length();
|
||||
result.text += nested.text;
|
||||
for(auto l : nested.links) {
|
||||
l.start_pos += offset;
|
||||
l.end_pos += offset;
|
||||
result.links.push_back(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Legacy support
|
||||
std::vector<RenderedLine> render_legacy(const ParsedDocument& /*doc*/, int /*screen_width*/) {
|
||||
return {}; // Not used anymore
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Public Interface
|
||||
// ============================================================================
|
||||
|
||||
TextRenderer::TextRenderer() : pImpl(std::make_unique<Impl>()) {}
|
||||
TextRenderer::~TextRenderer() = default;
|
||||
|
||||
std::vector<RenderedLine> TextRenderer::render_tree(const DocumentTree& tree, int screen_width) {
|
||||
return pImpl->render_tree(tree, screen_width);
|
||||
}
|
||||
|
||||
std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int screen_width) {
|
||||
return pImpl->render_legacy(doc, screen_width);
|
||||
}
|
||||
|
||||
void TextRenderer::set_config(const RenderConfig& config) {
|
||||
pImpl->config = config;
|
||||
}
|
||||
|
||||
RenderConfig TextRenderer::get_config() const {
|
||||
return pImpl->config;
|
||||
}
|
||||
|
||||
void init_color_scheme() {
|
||||
init_pair(COLOR_NORMAL, COLOR_WHITE, COLOR_BLACK);
|
||||
init_pair(COLOR_HEADING1, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_HEADING2, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_HEADING3, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_LINK, COLOR_YELLOW, COLOR_BLACK);
|
||||
init_pair(COLOR_LINK_ACTIVE, COLOR_YELLOW, COLOR_BLUE);
|
||||
init_pair(COLOR_STATUS_BAR, COLOR_BLACK, COLOR_WHITE);
|
||||
init_pair(COLOR_URL_BAR, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_SEARCH_HIGHLIGHT, COLOR_BLACK, COLOR_YELLOW);
|
||||
init_pair(COLOR_DIM, COLOR_WHITE, COLOR_BLACK);
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "html_parser.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <curses.h>
|
||||
|
||||
// Forward declarations
|
||||
struct DocumentTree;
|
||||
struct DomNode;
|
||||
|
||||
struct InteractiveRange {
|
||||
size_t start;
|
||||
size_t end;
|
||||
int link_index = -1;
|
||||
int field_index = -1;
|
||||
};
|
||||
|
||||
struct RenderedLine {
|
||||
std::string text;
|
||||
int color_pair;
|
||||
bool is_bold;
|
||||
bool is_link;
|
||||
int link_index;
|
||||
std::vector<InteractiveRange> interactive_ranges;
|
||||
};
|
||||
|
||||
// Unicode装饰字符
|
||||
namespace UnicodeChars {
|
||||
// 框线字符 (Box Drawing)
|
||||
constexpr const char* DBL_HORIZONTAL = "═";
|
||||
constexpr const char* DBL_VERTICAL = "║";
|
||||
constexpr const char* DBL_TOP_LEFT = "╔";
|
||||
constexpr const char* DBL_TOP_RIGHT = "╗";
|
||||
constexpr const char* DBL_BOTTOM_LEFT = "╚";
|
||||
constexpr const char* DBL_BOTTOM_RIGHT = "╝";
|
||||
|
||||
constexpr const char* SGL_HORIZONTAL = "─";
|
||||
constexpr const char* SGL_VERTICAL = "│";
|
||||
constexpr const char* SGL_TOP_LEFT = "┌";
|
||||
constexpr const char* SGL_TOP_RIGHT = "┐";
|
||||
constexpr const char* SGL_BOTTOM_LEFT = "└";
|
||||
constexpr const char* SGL_BOTTOM_RIGHT = "┘";
|
||||
constexpr const char* SGL_CROSS = "┼";
|
||||
constexpr const char* SGL_T_DOWN = "┬";
|
||||
constexpr const char* SGL_T_UP = "┴";
|
||||
constexpr const char* SGL_T_RIGHT = "├";
|
||||
constexpr const char* SGL_T_LEFT = "┤";
|
||||
|
||||
constexpr const char* HEAVY_HORIZONTAL = "━";
|
||||
constexpr const char* HEAVY_VERTICAL = "┃";
|
||||
|
||||
// 列表符号
|
||||
constexpr const char* BULLET = "•";
|
||||
constexpr const char* CIRCLE = "◦";
|
||||
constexpr const char* SQUARE = "▪";
|
||||
constexpr const char* TRIANGLE = "‣";
|
||||
|
||||
// 装饰符号
|
||||
constexpr const char* SECTION = "§";
|
||||
constexpr const char* PARAGRAPH = "¶";
|
||||
constexpr const char* ARROW_RIGHT = "→";
|
||||
constexpr const char* ELLIPSIS = "…";
|
||||
}
|
||||
|
||||
struct RenderConfig {
|
||||
// 布局设置
|
||||
int max_width = 80; // 最大内容宽度
|
||||
int margin_left = 0; // 左边距
|
||||
bool center_content = false; // 内容居中
|
||||
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使用下划线
|
||||
};
|
||||
|
||||
// 渲染上下文
|
||||
struct RenderContext {
|
||||
int screen_width; // 终端宽度
|
||||
int current_indent; // 当前缩进级别
|
||||
int nesting_level; // 列表嵌套层级
|
||||
int color_pair; // 当前颜色
|
||||
bool is_bold; // 是否加粗
|
||||
};
|
||||
|
||||
class TextRenderer {
|
||||
public:
|
||||
TextRenderer();
|
||||
~TextRenderer();
|
||||
|
||||
// 新接口:从DOM树渲染
|
||||
std::vector<RenderedLine> render_tree(const DocumentTree& tree, int screen_width);
|
||||
|
||||
// 旧接口:向后兼容
|
||||
std::vector<RenderedLine> render(const ParsedDocument& doc, int screen_width);
|
||||
|
||||
void set_config(const RenderConfig& config);
|
||||
RenderConfig get_config() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
||||
enum ColorScheme {
|
||||
COLOR_NORMAL = 1,
|
||||
COLOR_HEADING1,
|
||||
COLOR_HEADING2,
|
||||
COLOR_HEADING3,
|
||||
COLOR_LINK,
|
||||
COLOR_LINK_ACTIVE,
|
||||
COLOR_STATUS_BAR,
|
||||
COLOR_URL_BAR,
|
||||
COLOR_SEARCH_HIGHLIGHT,
|
||||
COLOR_DIM
|
||||
};
|
||||
|
||||
void init_color_scheme();
|
||||
7988
src/utils/stb_image.h
Normal file
7988
src/utils/stb_image.h
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Inline Links</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Page for Inline Links</h1>
|
||||
|
||||
<p>This is a paragraph with an <a href="https://example.com">inline link</a> in the middle of the text. You should be able to see the link highlighted directly in the text.</p>
|
||||
|
||||
<p>Here is another paragraph with multiple links: <a href="https://google.com">Google</a> and <a href="https://github.com">GitHub</a> are both popular websites.</p>
|
||||
|
||||
<p>This paragraph has a longer link text: <a href="https://en.wikipedia.org">Wikipedia is a free online encyclopedia</a> that anyone can edit.</p>
|
||||
|
||||
<h2>More Examples</h2>
|
||||
|
||||
<p>Press Tab to navigate between links, and Enter to follow them. The links should be <a href="https://example.com/test1">highlighted</a> directly in the text, not listed separately at the bottom.</p>
|
||||
|
||||
<ul>
|
||||
<li>List item with <a href="https://news.ycombinator.com">Hacker News</a></li>
|
||||
<li>Another item with <a href="https://reddit.com">Reddit</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>POST Form Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Form Method Test</h1>
|
||||
|
||||
<h2>GET Form</h2>
|
||||
<form action="https://httpbin.org/get" method="get">
|
||||
<p>Name: <input type="text" name="name" value="John"></p>
|
||||
<p>Email: <input type="text" name="email" value="john@example.com"></p>
|
||||
<p><input type="submit" value="Submit GET"></p>
|
||||
</form>
|
||||
|
||||
<h2>POST Form</h2>
|
||||
<form action="https://httpbin.org/post" method="post">
|
||||
<p>Username: <input type="text" name="username" value="testuser"></p>
|
||||
<p>Password: <input type="password" name="password" value="secret123"></p>
|
||||
<p>Message: <input type="text" name="message" value="Hello World"></p>
|
||||
<p><input type="submit" value="Submit POST"></p>
|
||||
</form>
|
||||
|
||||
<h2>Form with Special Characters</h2>
|
||||
<form action="https://httpbin.org/post" method="post">
|
||||
<p>Text: <input type="text" name="text" value="Hello & goodbye!"></p>
|
||||
<p>Code: <input type="text" name="code" value="a=b&c=d"></p>
|
||||
<p><input type="submit" value="Submit"></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<html>
|
||||
<body>
|
||||
<h1>Table Test</h1>
|
||||
<p>This is a paragraph before the table.</p>
|
||||
<table border="1">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>Item One</td>
|
||||
<td>This is a long description for item one to test wrapping.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>Item Two</td>
|
||||
<td>Short desc.</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>This is a paragraph after the table.</p>
|
||||
</body>
|
||||
</html>
|
||||
103
tests/test_bookmark.cpp
Normal file
103
tests/test_bookmark.cpp
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#include "bookmark.h"
|
||||
#include <iostream>
|
||||
#include <cstdio>
|
||||
|
||||
int main() {
|
||||
std::cout << "=== TUT 2.0 Bookmark Test ===" << std::endl;
|
||||
|
||||
// Note: Uses default path ~/.config/tut/bookmarks.json
|
||||
// We'll test in-memory operations and clean up
|
||||
|
||||
tut::BookmarkManager manager;
|
||||
|
||||
// Store original count to restore later
|
||||
size_t original_count = manager.count();
|
||||
std::cout << " Original bookmark count: " << original_count << std::endl;
|
||||
|
||||
// Test 1: Add bookmarks
|
||||
std::cout << "\n[Test 1] Add bookmarks..." << std::endl;
|
||||
|
||||
// Use unique URLs to avoid conflicts with existing bookmarks
|
||||
std::string test_url1 = "https://test-example-12345.com";
|
||||
std::string test_url2 = "https://test-google-12345.com";
|
||||
std::string test_url3 = "https://test-github-12345.com";
|
||||
|
||||
bool added1 = manager.add(test_url1, "Test Example");
|
||||
bool added2 = manager.add(test_url2, "Test Google");
|
||||
bool added3 = manager.add(test_url3, "Test GitHub");
|
||||
|
||||
if (added1 && added2 && added3) {
|
||||
std::cout << " ✓ Added 3 bookmarks" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Failed to add bookmarks" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 2: Duplicate detection
|
||||
std::cout << "\n[Test 2] Duplicate detection..." << std::endl;
|
||||
|
||||
bool duplicate = manager.add(test_url1, "Duplicate");
|
||||
if (!duplicate) {
|
||||
std::cout << " ✓ Duplicate correctly rejected" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Duplicate was incorrectly added" << std::endl;
|
||||
// Clean up and fail
|
||||
manager.remove(test_url1);
|
||||
manager.remove(test_url2);
|
||||
manager.remove(test_url3);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 3: Check existence
|
||||
std::cout << "\n[Test 3] Check existence..." << std::endl;
|
||||
|
||||
if (manager.contains(test_url1) && !manager.contains("https://notexist-12345.com")) {
|
||||
std::cout << " ✓ Existence check passed" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Existence check failed" << std::endl;
|
||||
manager.remove(test_url1);
|
||||
manager.remove(test_url2);
|
||||
manager.remove(test_url3);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 4: Count check
|
||||
std::cout << "\n[Test 4] Count check..." << std::endl;
|
||||
|
||||
if (manager.count() == original_count + 3) {
|
||||
std::cout << " ✓ Bookmark count correct: " << manager.count() << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Bookmark count incorrect" << std::endl;
|
||||
manager.remove(test_url1);
|
||||
manager.remove(test_url2);
|
||||
manager.remove(test_url3);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 5: Remove bookmark
|
||||
std::cout << "\n[Test 5] Remove bookmark..." << std::endl;
|
||||
|
||||
bool removed = manager.remove(test_url2);
|
||||
if (removed && !manager.contains(test_url2) && manager.count() == original_count + 2) {
|
||||
std::cout << " ✓ Bookmark removed successfully" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Bookmark removal failed" << std::endl;
|
||||
manager.remove(test_url1);
|
||||
manager.remove(test_url3);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Clean up test bookmarks
|
||||
std::cout << "\n[Cleanup] Removing test bookmarks..." << std::endl;
|
||||
manager.remove(test_url1);
|
||||
manager.remove(test_url3);
|
||||
|
||||
if (manager.count() == original_count) {
|
||||
std::cout << " ✓ Cleanup successful, restored to " << original_count << " bookmarks" << std::endl;
|
||||
} else {
|
||||
std::cout << " ⚠ Cleanup may have issues" << std::endl;
|
||||
}
|
||||
|
||||
std::cout << "\n=== All bookmark tests passed! ===" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
73
tests/test_history.cpp
Normal file
73
tests/test_history.cpp
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#include "history.h"
|
||||
#include <iostream>
|
||||
#include <cstdio>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
using namespace tut;
|
||||
|
||||
int main() {
|
||||
std::cout << "=== TUT 2.0 History Test ===" << std::endl;
|
||||
|
||||
// 记录初始状态
|
||||
HistoryManager manager;
|
||||
size_t initial_count = manager.count();
|
||||
std::cout << " Original history count: " << initial_count << std::endl;
|
||||
|
||||
// Test 1: 添加历史记录
|
||||
std::cout << "\n[Test 1] Add history entries..." << std::endl;
|
||||
manager.add("https://example.com", "Example Site");
|
||||
manager.add("https://test.com", "Test Site");
|
||||
manager.add("https://demo.com", "Demo Site");
|
||||
|
||||
if (manager.count() == initial_count + 3) {
|
||||
std::cout << " ✓ Added 3 entries" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Failed to add entries" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 2: 重复 URL 更新
|
||||
std::cout << "\n[Test 2] Duplicate URL update..." << std::endl;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
manager.add("https://example.com", "Example Site Updated");
|
||||
|
||||
// 计数应该不变(因为重复的会被移到前面而不是新增)
|
||||
if (manager.count() == initial_count + 3) {
|
||||
std::cout << " ✓ Duplicate correctly handled" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Duplicate handling failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 3: 最新在前面
|
||||
std::cout << "\n[Test 3] Most recent first..." << std::endl;
|
||||
const auto& entries = manager.get_all();
|
||||
if (!entries.empty() && entries[0].url == "https://example.com") {
|
||||
std::cout << " ✓ Most recent entry is first" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Order incorrect" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 4: 持久化
|
||||
std::cout << "\n[Test 4] Persistence..." << std::endl;
|
||||
{
|
||||
HistoryManager manager2; // 创建新实例会加载
|
||||
if (manager2.count() >= initial_count + 3) {
|
||||
std::cout << " ✓ History persisted to file" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Persistence failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: 移除测试条目
|
||||
std::cout << "\n[Cleanup] Removing test entries..." << std::endl;
|
||||
HistoryManager cleanup_manager;
|
||||
// 由于我们没有删除单条的方法,这里只验证功能
|
||||
// 在实际使用中,历史会随着时间自然过期
|
||||
|
||||
std::cout << "\n=== All history tests passed! ===" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
129
tests/test_html_parse.cpp
Normal file
129
tests/test_html_parse.cpp
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#include "html_parser.h"
|
||||
#include "dom_tree.h"
|
||||
#include <iostream>
|
||||
#include <cassert>
|
||||
|
||||
int main() {
|
||||
std::cout << "=== TUT 2.0 HTML Parser Test ===" << std::endl;
|
||||
|
||||
HtmlParser parser;
|
||||
|
||||
// Test 1: Basic HTML parsing
|
||||
std::cout << "\n[Test 1] Basic HTML parsing..." << std::endl;
|
||||
std::string html1 = R"(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test Page</title></head>
|
||||
<body>
|
||||
<h1>Hello World</h1>
|
||||
<p>This is a <a href="https://example.com">link</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
auto tree1 = parser.parse_tree(html1, "https://test.com");
|
||||
std::cout << " ✓ Title: " << tree1.title << std::endl;
|
||||
std::cout << " ✓ Links found: " << tree1.links.size() << std::endl;
|
||||
|
||||
if (tree1.title == "Test Page" && tree1.links.size() == 1) {
|
||||
std::cout << " ✓ Basic parsing passed" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Basic parsing failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 2: Link URL resolution
|
||||
std::cout << "\n[Test 2] Link URL resolution..." << std::endl;
|
||||
std::string html2 = R"(
|
||||
<html>
|
||||
<body>
|
||||
<a href="/relative">Relative</a>
|
||||
<a href="https://absolute.com">Absolute</a>
|
||||
<a href="page.html">Same dir</a>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
auto tree2 = parser.parse_tree(html2, "https://base.com/dir/");
|
||||
std::cout << " Found " << tree2.links.size() << " links:" << std::endl;
|
||||
for (const auto& link : tree2.links) {
|
||||
std::cout << " - " << link.url << std::endl;
|
||||
}
|
||||
|
||||
if (tree2.links.size() == 3) {
|
||||
std::cout << " ✓ Link resolution passed" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Link resolution failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 3: Form parsing
|
||||
std::cout << "\n[Test 3] Form parsing..." << std::endl;
|
||||
std::string html3 = R"(
|
||||
<html>
|
||||
<body>
|
||||
<form action="/submit" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="password" name="password">
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
auto tree3 = parser.parse_tree(html3, "https://form.com");
|
||||
std::cout << " Form fields found: " << tree3.form_fields.size() << std::endl;
|
||||
|
||||
if (tree3.form_fields.size() >= 2) {
|
||||
std::cout << " ✓ Form parsing passed" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Form parsing failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 4: Image parsing
|
||||
std::cout << "\n[Test 4] Image parsing..." << std::endl;
|
||||
std::string html4 = R"(
|
||||
<html>
|
||||
<body>
|
||||
<img src="image1.png" alt="Image 1">
|
||||
<img src="/images/image2.jpg" alt="Image 2">
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
auto tree4 = parser.parse_tree(html4, "https://images.com/page/");
|
||||
std::cout << " Images found: " << tree4.images.size() << std::endl;
|
||||
|
||||
if (tree4.images.size() == 2) {
|
||||
std::cout << " ✓ Image parsing passed" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Image parsing failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 5: Unicode content
|
||||
std::cout << "\n[Test 5] Unicode content..." << std::endl;
|
||||
std::string html5 = R"(
|
||||
<html>
|
||||
<head><title>中文标题</title></head>
|
||||
<body>
|
||||
<h1>日本語テスト</h1>
|
||||
<p>한국어 테스트</p>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
auto tree5 = parser.parse_tree(html5, "https://unicode.com");
|
||||
std::cout << " ✓ Title: " << tree5.title << std::endl;
|
||||
|
||||
if (tree5.title == "中文标题") {
|
||||
std::cout << " ✓ Unicode parsing passed" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Unicode parsing failed" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "\n=== All HTML parser tests passed! ===" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
84
tests/test_http_async.cpp
Normal file
84
tests/test_http_async.cpp
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#include "http_client.h"
|
||||
#include <iostream>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
int main() {
|
||||
std::cout << "=== TUT 2.0 HTTP Async Test ===" << std::endl;
|
||||
|
||||
HttpClient client;
|
||||
|
||||
// Test 1: Synchronous fetch
|
||||
std::cout << "\n[Test 1] Synchronous fetch..." << std::endl;
|
||||
auto response = client.fetch("https://example.com");
|
||||
if (response.is_success()) {
|
||||
std::cout << " ✓ Status: " << response.status_code << std::endl;
|
||||
std::cout << " ✓ Content-Type: " << response.content_type << std::endl;
|
||||
std::cout << " ✓ Body length: " << response.body.length() << " bytes" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Failed: " << response.error_message << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Test 2: Asynchronous fetch
|
||||
std::cout << "\n[Test 2] Asynchronous fetch..." << std::endl;
|
||||
client.start_async_fetch("https://example.com");
|
||||
|
||||
int polls = 0;
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
|
||||
while (true) {
|
||||
auto state = client.poll_async();
|
||||
polls++;
|
||||
|
||||
if (state == AsyncState::COMPLETE) {
|
||||
auto end = std::chrono::steady_clock::now();
|
||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
|
||||
|
||||
auto result = client.get_async_result();
|
||||
std::cout << " ✓ Completed in " << ms << "ms after " << polls << " polls" << std::endl;
|
||||
std::cout << " ✓ Status: " << result.status_code << std::endl;
|
||||
std::cout << " ✓ Body length: " << result.body.length() << " bytes" << std::endl;
|
||||
break;
|
||||
} else if (state == AsyncState::FAILED) {
|
||||
auto result = client.get_async_result();
|
||||
std::cout << " ✗ Failed: " << result.error_message << std::endl;
|
||||
return 1;
|
||||
} else if (state == AsyncState::LOADING) {
|
||||
// Non-blocking poll
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
} else {
|
||||
std::cout << " ✗ Unexpected state" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (polls > 1000) {
|
||||
std::cout << " ✗ Timeout" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: Cancel async
|
||||
std::cout << "\n[Test 3] Cancel async..." << std::endl;
|
||||
client.start_async_fetch("https://httpbin.org/delay/10");
|
||||
|
||||
// Poll a few times then cancel
|
||||
for (int i = 0; i < 5; i++) {
|
||||
client.poll_async();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
|
||||
client.cancel_async();
|
||||
std::cout << " ✓ Request cancelled" << std::endl;
|
||||
|
||||
// Verify state is CANCELLED or IDLE
|
||||
if (!client.is_async_active()) {
|
||||
std::cout << " ✓ No active request after cancel" << std::endl;
|
||||
} else {
|
||||
std::cout << " ✗ Request still active after cancel" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "\n=== All tests passed! ===" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue