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'
|
if: matrix.os == 'macos-latest'
|
||||||
run: |
|
run: |
|
||||||
brew update
|
brew update
|
||||||
brew install cmake ncurses curl
|
brew install cmake ncurses curl gumbo-parser
|
||||||
|
|
||||||
- name: Install dependencies (Linux)
|
- name: Install dependencies (Linux)
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
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
|
- name: Configure CMake
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -47,7 +47,7 @@ jobs:
|
||||||
|
|
||||||
- name: Rename binary with platform suffix
|
- name: Rename binary with platform suffix
|
||||||
run: |
|
run: |
|
||||||
mv build/tut build/tut-${{ matrix.name }}
|
mv build/tut2 build/tut-${{ matrix.name }}
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|
|
||||||
103
CMakeLists.txt
103
CMakeLists.txt
|
|
@ -1,5 +1,5 @@
|
||||||
cmake_minimum_required(VERSION 3.15)
|
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标准
|
# C++17标准
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
|
@ -23,20 +23,44 @@ pkg_check_modules(GUMBO REQUIRED gumbo)
|
||||||
# 包含目录
|
# 包含目录
|
||||||
include_directories(
|
include_directories(
|
||||||
${CMAKE_SOURCE_DIR}/src
|
${CMAKE_SOURCE_DIR}/src
|
||||||
${CMAKE_SOURCE_DIR}/src/core
|
|
||||||
${CMAKE_SOURCE_DIR}/src/render
|
${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
|
${CMAKE_SOURCE_DIR}/src/utils
|
||||||
${CURL_INCLUDE_DIRS}
|
${CURL_INCLUDE_DIRS}
|
||||||
${CURSES_INCLUDE_DIRS}
|
${CURSES_INCLUDE_DIRS}
|
||||||
${GUMBO_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
|
add_executable(test_terminal
|
||||||
src/render/terminal.cpp
|
src/render/terminal.cpp
|
||||||
tests/test_terminal.cpp
|
tests/test_terminal.cpp
|
||||||
|
|
@ -46,8 +70,7 @@ target_link_libraries(test_terminal
|
||||||
${CURSES_LIBRARIES}
|
${CURSES_LIBRARIES}
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==================== Renderer 测试程序 ====================
|
# Renderer 测试
|
||||||
|
|
||||||
add_executable(test_renderer
|
add_executable(test_renderer
|
||||||
src/render/terminal.cpp
|
src/render/terminal.cpp
|
||||||
src/render/renderer.cpp
|
src/render/renderer.cpp
|
||||||
|
|
@ -59,8 +82,7 @@ target_link_libraries(test_renderer
|
||||||
${CURSES_LIBRARIES}
|
${CURSES_LIBRARIES}
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==================== Layout 测试程序 ====================
|
# Layout 测试
|
||||||
|
|
||||||
add_executable(test_layout
|
add_executable(test_layout
|
||||||
src/render/terminal.cpp
|
src/render/terminal.cpp
|
||||||
src/render/renderer.cpp
|
src/render/renderer.cpp
|
||||||
|
|
@ -81,50 +103,39 @@ target_link_libraries(test_layout
|
||||||
${GUMBO_LIBRARIES}
|
${GUMBO_LIBRARIES}
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==================== TUT 2.0 主程序 ====================
|
# HTTP 异步测试
|
||||||
|
add_executable(test_http_async
|
||||||
add_executable(tut2
|
|
||||||
src/main_v2.cpp
|
|
||||||
src/browser_v2.cpp
|
|
||||||
src/http_client.cpp
|
src/http_client.cpp
|
||||||
src/input_handler.cpp
|
tests/test_http_async.cpp
|
||||||
src/render/terminal.cpp
|
|
||||||
src/render/renderer.cpp
|
|
||||||
src/render/layout.cpp
|
|
||||||
src/render/image.cpp
|
|
||||||
src/utils/unicode.cpp
|
|
||||||
src/dom_tree.cpp
|
|
||||||
src/html_parser.cpp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_directories(tut2 PRIVATE
|
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}
|
${GUMBO_LIBRARY_DIRS}
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(tut2
|
target_link_libraries(test_html_parse
|
||||||
${CURSES_LIBRARIES}
|
|
||||||
CURL::libcurl
|
|
||||||
${GUMBO_LIBRARIES}
|
${GUMBO_LIBRARIES}
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==================== 旧版主程序 (向后兼容) ====================
|
# 书签测试
|
||||||
|
add_executable(test_bookmark
|
||||||
add_executable(tut
|
src/bookmark.cpp
|
||||||
src/main.cpp
|
tests/test_bookmark.cpp
|
||||||
src/browser.cpp
|
|
||||||
src/http_client.cpp
|
|
||||||
src/text_renderer.cpp
|
|
||||||
src/input_handler.cpp
|
|
||||||
src/dom_tree.cpp
|
|
||||||
src/html_parser.cpp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_directories(tut PRIVATE
|
# 历史记录测试
|
||||||
${GUMBO_LIBRARY_DIRS}
|
add_executable(test_history
|
||||||
)
|
src/history.cpp
|
||||||
|
tests/test_history.cpp
|
||||||
target_link_libraries(tut
|
|
||||||
${CURSES_LIBRARIES}
|
|
||||||
CURL::libcurl
|
|
||||||
${GUMBO_LIBRARIES}
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
130
NEXT_STEPS.md
130
NEXT_STEPS.md
|
|
@ -1,40 +1,53 @@
|
||||||
# TUT 2.0 - 下次继续从这里开始
|
# TUT 2.0 - 下次继续从这里开始
|
||||||
|
|
||||||
## 当前位置
|
## 当前位置
|
||||||
- **阶段**: Phase 4 - 图片支持 (基础完成)
|
- **阶段**: Phase 7 - 历史记录持久化 (已完成!)
|
||||||
- **进度**: 占位符显示已完成,ASCII Art 渲染框架就绪
|
- **进度**: 历史记录自动保存,支持 :history 命令查看
|
||||||
- **最后提交**: `d80d0a1 feat: Implement TUT 2.0 with new rendering architecture`
|
- **最后提交**: `feat: Add persistent browsing history`
|
||||||
- **待推送**: 本地有 3 个提交未推送到 origin/main
|
|
||||||
|
|
||||||
## 立即可做的事
|
## 立即可做的事
|
||||||
|
|
||||||
### 1. 推送代码到远程
|
### 1. 使用书签功能
|
||||||
```bash
|
- **B** - 添加当前页面到书签
|
||||||
git push origin main
|
- **D** - 从书签中移除当前页面
|
||||||
```
|
- **:bookmarks** 或 **:bm** - 查看书签列表
|
||||||
|
|
||||||
### 2. 启用完整图片支持 (PNG/JPEG)
|
书签存储在 `~/.config/tut/bookmarks.json`
|
||||||
```bash
|
|
||||||
# 下载 stb_image.h
|
|
||||||
curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \
|
|
||||||
-o src/utils/stb_image.h
|
|
||||||
|
|
||||||
# 重新编译
|
### 2. 查看历史记录
|
||||||
cmake --build build_v2
|
- **:history** 或 **:hist** - 查看浏览历史
|
||||||
|
|
||||||
# 编译后 ImageRenderer::load_from_memory() 将自动支持 PNG/JPEG/GIF/BMP
|
历史记录存储在 `~/.config/tut/history.json`
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 在浏览器中集成图片渲染
|
|
||||||
需要在 `browser_v2.cpp` 中:
|
|
||||||
1. 收集页面中的所有 `<img>` 标签
|
|
||||||
2. 使用 `HttpClient::fetch_binary()` 下载图片
|
|
||||||
3. 调用 `ImageRenderer::load_from_memory()` 解码
|
|
||||||
4. 调用 `ImageRenderer::render()` 生成 ASCII Art
|
|
||||||
5. 将结果插入到布局中
|
|
||||||
|
|
||||||
## 已完成的功能清单
|
## 已完成的功能清单
|
||||||
|
|
||||||
|
### 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 - 图片支持
|
### Phase 4 - 图片支持
|
||||||
- [x] `<img>` 标签解析 (src, alt, width, height)
|
- [x] `<img>` 标签解析 (src, alt, width, height)
|
||||||
- [x] 图片占位符显示 `[alt text]` 或 `[Image: filename]`
|
- [x] 图片占位符显示 `[alt text]` 或 `[Image: filename]`
|
||||||
|
|
@ -42,8 +55,9 @@ cmake --build build_v2
|
||||||
- [x] `HttpClient::fetch_binary()` 方法
|
- [x] `HttpClient::fetch_binary()` 方法
|
||||||
- [x] `ImageRenderer` 类框架
|
- [x] `ImageRenderer` 类框架
|
||||||
- [x] PPM 格式内置解码
|
- [x] PPM 格式内置解码
|
||||||
- [ ] stb_image.h 集成 (需手动下载)
|
- [x] stb_image.h 集成 (PNG/JPEG/GIF/BMP 支持)
|
||||||
- [ ] 浏览器中的图片下载和渲染
|
- [x] 浏览器中的图片下载和渲染
|
||||||
|
- [x] ASCII Art 彩色渲染 (True Color)
|
||||||
|
|
||||||
### Phase 3 - 性能优化
|
### Phase 3 - 性能优化
|
||||||
- [x] LRU 页面缓存 (20页, 5分钟过期)
|
- [x] LRU 页面缓存 (20页, 5分钟过期)
|
||||||
|
|
@ -72,12 +86,14 @@ cmake --build build_v2
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── browser_v2.cpp/h # 新架构浏览器 (pImpl模式)
|
├── browser.cpp/h # 主浏览器 (pImpl模式)
|
||||||
├── main_v2.cpp # tut2 入口点
|
├── main.cpp # 程序入口点
|
||||||
├── http_client.cpp/h # HTTP 客户端 (支持二进制)
|
├── http_client.cpp/h # HTTP 客户端 (支持二进制和异步)
|
||||||
├── dom_tree.cpp/h # DOM 树
|
├── dom_tree.cpp/h # DOM 树
|
||||||
├── html_parser.cpp/h # HTML 解析
|
├── html_parser.cpp/h # HTML 解析
|
||||||
├── input_handler.cpp/h # 输入处理
|
├── input_handler.cpp/h # 输入处理
|
||||||
|
├── bookmark.cpp/h # 书签管理
|
||||||
|
├── history.cpp/h # 历史记录管理
|
||||||
├── render/
|
├── render/
|
||||||
│ ├── terminal.cpp/h # 终端抽象 (ncurses)
|
│ ├── terminal.cpp/h # 终端抽象 (ncurses)
|
||||||
│ ├── renderer.cpp/h # FrameBuffer + 差分渲染
|
│ ├── renderer.cpp/h # FrameBuffer + 差分渲染
|
||||||
|
|
@ -87,29 +103,36 @@ src/
|
||||||
│ └── decorations.h # Unicode 装饰字符
|
│ └── decorations.h # Unicode 装饰字符
|
||||||
└── utils/
|
└── utils/
|
||||||
├── unicode.cpp/h # Unicode 处理
|
├── unicode.cpp/h # Unicode 处理
|
||||||
└── stb_image.h # [需下载] 图片解码库
|
└── stb_image.h # 图片解码库
|
||||||
|
|
||||||
tests/
|
tests/
|
||||||
├── test_terminal.cpp # Terminal 测试
|
├── test_terminal.cpp # Terminal 测试
|
||||||
├── test_renderer.cpp # Renderer 测试
|
├── 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
|
```bash
|
||||||
# 构建
|
# 构建
|
||||||
cmake -B build_v2 -S . -DCMAKE_BUILD_TYPE=Debug
|
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
|
||||||
cmake --build build_v2
|
cmake --build build
|
||||||
|
|
||||||
# 运行
|
# 运行
|
||||||
./build_v2/tut2 # 显示帮助
|
./build/tut # 显示帮助
|
||||||
./build_v2/tut2 https://example.com # 打开网页
|
./build/tut https://example.com # 打开网页
|
||||||
|
|
||||||
# 测试
|
# 测试
|
||||||
./build_v2/test_terminal # 终端测试
|
./build/test_terminal # 终端测试
|
||||||
./build_v2/test_renderer # 渲染测试
|
./build/test_renderer # 渲染测试
|
||||||
./build_v2/test_layout # 布局+图片测试 (按回车进入交互模式)
|
./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 | 下一个/上一个匹配 |
|
| n/N | 下一个/上一个匹配 |
|
||||||
| r | 刷新 (跳过缓存) |
|
| r | 刷新 (跳过缓存) |
|
||||||
|
| B | 添加书签 |
|
||||||
|
| D | 删除书签 |
|
||||||
| :o URL | 打开URL |
|
| :o URL | 打开URL |
|
||||||
|
| :bookmarks | 查看书签 |
|
||||||
|
| :history | 查看历史 |
|
||||||
| :q | 退出 |
|
| :q | 退出 |
|
||||||
| ? | 帮助 |
|
| ? | 帮助 |
|
||||||
|
| Esc | 取消加载 |
|
||||||
|
|
||||||
## 下一步功能优先级
|
## 下一步功能优先级
|
||||||
|
|
||||||
1. **完成图片 ASCII Art 渲染** - 下载 stb_image.h 并集成到浏览器
|
1. **更多表单交互** - 文本输入编辑,下拉选择
|
||||||
2. **书签管理** - 添加/删除书签,书签列表页面,持久化存储
|
2. **图片缓存** - 避免重复下载相同图片
|
||||||
3. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求
|
3. **异步图片加载** - 图片也使用异步加载
|
||||||
4. **更多表单交互** - 文本输入编辑,下拉选择
|
4. **Cookie 支持** - 保存和发送 Cookie
|
||||||
|
|
||||||
## 恢复对话时说
|
## 恢复对话时说
|
||||||
|
|
||||||
> "继续TUT 2.0开发"
|
> "继续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 "http_client.h"
|
||||||
#include "html_parser.h"
|
#include "html_parser.h"
|
||||||
#include "text_renderer.h"
|
|
||||||
#include "input_handler.h"
|
#include "input_handler.h"
|
||||||
|
#include "render/terminal.h"
|
||||||
|
#include "render/renderer.h"
|
||||||
|
#include "render/layout.h"
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser - TUT 终端浏览器
|
||||||
|
*
|
||||||
|
* 使用 Terminal + FrameBuffer + Renderer + LayoutEngine 架构
|
||||||
|
* 支持 True Color, Unicode, 差分渲染
|
||||||
|
*/
|
||||||
class Browser {
|
class Browser {
|
||||||
public:
|
public:
|
||||||
Browser();
|
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 == "pre" || tag_name == "hr" ||
|
||||||
tag_name == "table" || tag_name == "tr" ||
|
tag_name == "table" || tag_name == "tr" ||
|
||||||
tag_name == "th" || tag_name == "td" ||
|
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 DomNode::get_all_text() const {
|
||||||
std::string result;
|
std::string result;
|
||||||
|
|
||||||
|
// 过滤不应该提取文本的元素
|
||||||
|
if (!should_render()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
if (node_type == NodeType::TEXT) {
|
if (node_type == NodeType::TEXT) {
|
||||||
result = text_content;
|
result = text_content;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -136,7 +146,7 @@ DocumentTree DomTreeBuilder::build(const std::string& html, const std::string& b
|
||||||
// 2. 转换为DomNode树
|
// 2. 转换为DomNode树
|
||||||
DocumentTree tree;
|
DocumentTree tree;
|
||||||
tree.url = base_url;
|
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. 提取标题
|
// 3. 提取标题
|
||||||
if (tree.root) {
|
if (tree.root) {
|
||||||
|
|
@ -153,6 +163,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
||||||
GumboNode* gumbo_node,
|
GumboNode* gumbo_node,
|
||||||
std::vector<Link>& links,
|
std::vector<Link>& links,
|
||||||
std::vector<DomNode*>& form_fields,
|
std::vector<DomNode*>& form_fields,
|
||||||
|
std::vector<DomNode*>& images,
|
||||||
const std::string& base_url
|
const std::string& base_url
|
||||||
) {
|
) {
|
||||||
if (!gumbo_node) return nullptr;
|
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->tag_name = gumbo_normalized_tagname(element.tag);
|
||||||
node->element_type = map_gumbo_tag_to_element_type(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
|
// Assign current form ID to children
|
||||||
node->form_id = g_current_form_id;
|
node->form_id = g_current_form_id;
|
||||||
|
|
||||||
|
|
@ -282,6 +299,11 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
||||||
if (height_attr && height_attr->value) {
|
if (height_attr && height_attr->value) {
|
||||||
try { node->img_height = std::stoi(height_attr->value); } catch (...) {}
|
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]),
|
static_cast<GumboNode*>(children->data[i]),
|
||||||
links,
|
links,
|
||||||
form_fields,
|
form_fields,
|
||||||
|
images,
|
||||||
base_url
|
base_url
|
||||||
);
|
);
|
||||||
if (child) {
|
if (child) {
|
||||||
child->parent = node.get();
|
child->parent = node.get();
|
||||||
node->children.push_back(std::move(child));
|
node->children.push_back(std::move(child));
|
||||||
|
|
||||||
// For TEXTAREA, content is value
|
// For TEXTAREA, content is value
|
||||||
if (element.tag == GUMBO_TAG_TEXTAREA && child->node_type == NodeType::TEXT) {
|
if (element.tag == GUMBO_TAG_TEXTAREA && child->node_type == NodeType::TEXT) {
|
||||||
node->value += child->text_content;
|
node->value += child->text_content;
|
||||||
|
|
@ -371,6 +394,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
||||||
static_cast<GumboNode*>(doc.children.data[i]),
|
static_cast<GumboNode*>(doc.children.data[i]),
|
||||||
links,
|
links,
|
||||||
form_fields,
|
form_fields,
|
||||||
|
images,
|
||||||
base_url
|
base_url
|
||||||
);
|
);
|
||||||
if (child) {
|
if (child) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "html_parser.h"
|
#include "html_parser.h"
|
||||||
|
#include "render/image.h"
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
@ -40,6 +41,7 @@ struct DomNode {
|
||||||
std::string alt_text; // 图片alt文本
|
std::string alt_text; // 图片alt文本
|
||||||
int img_width = -1; // 图片宽度 (-1表示未指定)
|
int img_width = -1; // 图片宽度 (-1表示未指定)
|
||||||
int img_height = -1; // 图片高度 (-1表示未指定)
|
int img_height = -1; // 图片高度 (-1表示未指定)
|
||||||
|
tut::ImageData image_data; // 解码后的图片数据
|
||||||
|
|
||||||
// 表格属性
|
// 表格属性
|
||||||
bool is_table_header = false;
|
bool is_table_header = false;
|
||||||
|
|
@ -68,6 +70,7 @@ struct DocumentTree {
|
||||||
std::unique_ptr<DomNode> root;
|
std::unique_ptr<DomNode> root;
|
||||||
std::vector<Link> links; // 全局链接列表
|
std::vector<Link> links; // 全局链接列表
|
||||||
std::vector<DomNode*> form_fields; // 全局表单字段列表 (非拥有指针)
|
std::vector<DomNode*> form_fields; // 全局表单字段列表 (非拥有指针)
|
||||||
|
std::vector<DomNode*> images; // 全局图片列表 (非拥有指针)
|
||||||
std::string title;
|
std::string title;
|
||||||
std::string url;
|
std::string url;
|
||||||
};
|
};
|
||||||
|
|
@ -87,6 +90,7 @@ private:
|
||||||
GumboNode* gumbo_node,
|
GumboNode* gumbo_node,
|
||||||
std::vector<Link>& links,
|
std::vector<Link>& links,
|
||||||
std::vector<DomNode*>& form_fields,
|
std::vector<DomNode*>& form_fields,
|
||||||
|
std::vector<DomNode*>& images,
|
||||||
const std::string& base_url
|
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;
|
bool follow_redirects;
|
||||||
std::string cookie_file;
|
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),
|
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) {
|
follow_redirects(true) {
|
||||||
curl = curl_easy_init();
|
curl = curl_easy_init();
|
||||||
if (!curl) {
|
if (!curl) {
|
||||||
|
|
@ -36,13 +43,59 @@ public:
|
||||||
curl_easy_setopt(curl, CURLOPT_COOKIEFILE, "");
|
curl_easy_setopt(curl, CURLOPT_COOKIEFILE, "");
|
||||||
// Enable automatic decompression of supported encodings (gzip, deflate, etc.)
|
// Enable automatic decompression of supported encodings (gzip, deflate, etc.)
|
||||||
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
|
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() {
|
~Impl() {
|
||||||
|
// 清理异步请求
|
||||||
|
cleanup_async();
|
||||||
|
|
||||||
|
if (multi_handle) {
|
||||||
|
curl_multi_cleanup(multi_handle);
|
||||||
|
}
|
||||||
if (curl) {
|
if (curl) {
|
||||||
curl_easy_cleanup(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>()) {}
|
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) {
|
void HttpClient::enable_cookies(const std::string& cookie_file) {
|
||||||
pImpl->cookie_file = 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 <cstdint>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
|
// 异步请求状态
|
||||||
|
enum class AsyncState {
|
||||||
|
IDLE, // 无活跃请求
|
||||||
|
LOADING, // 请求进行中
|
||||||
|
COMPLETE, // 请求成功完成
|
||||||
|
FAILED, // 请求失败
|
||||||
|
CANCELLED // 请求被取消
|
||||||
|
};
|
||||||
|
|
||||||
struct HttpResponse {
|
struct HttpResponse {
|
||||||
int status_code;
|
int status_code;
|
||||||
std::string body;
|
std::string body;
|
||||||
|
|
@ -36,10 +45,20 @@ public:
|
||||||
HttpClient();
|
HttpClient();
|
||||||
~HttpClient();
|
~HttpClient();
|
||||||
|
|
||||||
|
// 同步请求接口
|
||||||
HttpResponse fetch(const std::string& url);
|
HttpResponse fetch(const std::string& url);
|
||||||
BinaryResponse fetch_binary(const std::string& url);
|
BinaryResponse fetch_binary(const std::string& url);
|
||||||
HttpResponse post(const std::string& url, const std::string& data,
|
HttpResponse post(const std::string& url, const std::string& data,
|
||||||
const std::string& content_type = "application/x-www-form-urlencoded");
|
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_timeout(long timeout_seconds);
|
||||||
void set_user_agent(const std::string& user_agent);
|
void set_user_agent(const std::string& user_agent);
|
||||||
void set_follow_redirects(bool follow);
|
void set_follow_redirects(bool follow);
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,12 @@ public:
|
||||||
case '?':
|
case '?':
|
||||||
result.action = Action::HELP;
|
result.action = Action::HELP;
|
||||||
break;
|
break;
|
||||||
|
case 'B':
|
||||||
|
result.action = Action::ADD_BOOKMARK;
|
||||||
|
break;
|
||||||
|
case 'D':
|
||||||
|
result.action = Action::REMOVE_BOOKMARK;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
break;
|
break;
|
||||||
|
|
@ -201,6 +207,10 @@ public:
|
||||||
result.action = Action::OPEN_URL;
|
result.action = Action::OPEN_URL;
|
||||||
result.text = command.substr(space_pos + 1);
|
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])) {
|
} else if (!command.empty() && std::isdigit(command[0])) {
|
||||||
try {
|
try {
|
||||||
result.action = Action::GOTO_LINE;
|
result.action = Action::GOTO_LINE;
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,11 @@ enum class Action {
|
||||||
QUIT,
|
QUIT,
|
||||||
HELP,
|
HELP,
|
||||||
SET_MARK, // Set a mark (m + letter)
|
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 {
|
struct InputResult {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
void print_usage(const char* prog_name) {
|
void print_usage(const char* prog_name) {
|
||||||
std::cout << "TUT - Terminal User Interface Browser\n"
|
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"
|
<< "Usage: " << prog_name << " [URL]\n\n"
|
||||||
<< "If no URL is provided, the browser will start with a help page.\n\n"
|
<< "If no URL is provided, the browser will start with a help page.\n\n"
|
||||||
<< "Examples:\n"
|
<< "Examples:\n"
|
||||||
|
|
@ -44,4 +44,3 @@ int main(int argc, char* argv[]) {
|
||||||
|
|
||||||
return 0;
|
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;
|
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 ||
|
if (node->element_type == ElementType::INPUT ||
|
||||||
node->element_type == ElementType::BUTTON ||
|
node->element_type == ElementType::BUTTON ||
|
||||||
|
|
@ -106,10 +92,86 @@ void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<La
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理块级元素
|
||||||
if (node->is_block_element()) {
|
if (node->is_block_element()) {
|
||||||
layout_block_element(node, ctx, blocks);
|
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) {
|
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_top = 0;
|
||||||
block.margin_bottom = 1;
|
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;
|
LayoutLine line;
|
||||||
line.indent = MARGIN_LEFT;
|
line.indent = MARGIN_LEFT;
|
||||||
|
|
||||||
// 生成图片占位符
|
|
||||||
std::string placeholder = make_image_placeholder(node->alt_text, node->img_src);
|
std::string placeholder = make_image_placeholder(node->alt_text, node->img_src);
|
||||||
|
|
||||||
StyledSpan span;
|
StyledSpan span;
|
||||||
|
|
@ -407,6 +547,41 @@ void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, s
|
||||||
blocks.push_back(block);
|
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) {
|
void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
|
|
@ -424,10 +599,21 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
|
||||||
int link_idx = node->link_index;
|
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) {
|
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;
|
StyledSpan span;
|
||||||
span.text = child->text_content;
|
span.text = text;
|
||||||
span.fg = fg;
|
span.fg = fg;
|
||||||
span.attrs = attrs;
|
span.attrs = attrs;
|
||||||
span.link_index = link_idx;
|
span.link_index = link_idx;
|
||||||
|
|
@ -438,6 +624,16 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
|
||||||
|
|
||||||
spans.push_back(span);
|
spans.push_back(span);
|
||||||
} else if (!child->is_block_element()) {
|
} 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);
|
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) {
|
void LayoutEngine::layout_text(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
|
||||||
if (!node || node->text_content.empty()) return;
|
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;
|
StyledSpan span;
|
||||||
span.text = node->text_content;
|
span.text = text;
|
||||||
span.fg = colors::FG_PRIMARY;
|
span.fg = colors::FG_PRIMARY;
|
||||||
|
|
||||||
if (ctx.in_blockquote) {
|
if (ctx.in_blockquote) {
|
||||||
|
|
@ -468,12 +673,13 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
|
||||||
LayoutLine current_line;
|
LayoutLine current_line;
|
||||||
current_line.indent = indent;
|
current_line.indent = indent;
|
||||||
size_t current_width = 0;
|
size_t current_width = 0;
|
||||||
|
bool is_line_start = true; // 整行的开始标记
|
||||||
|
|
||||||
for (const auto& span : spans) {
|
for (const auto& span : spans) {
|
||||||
// 分词处理
|
// 分词处理
|
||||||
std::istringstream iss(span.text);
|
std::istringstream iss(span.text);
|
||||||
std::string word;
|
std::string word;
|
||||||
bool first_word = true;
|
bool first_word_in_span = true;
|
||||||
|
|
||||||
while (iss >> word) {
|
while (iss >> word) {
|
||||||
size_t word_width = Unicode::display_width(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 = LayoutLine();
|
||||||
current_line.indent = indent;
|
current_line.indent = indent;
|
||||||
current_width = 0;
|
current_width = 0;
|
||||||
first_word = true;
|
is_line_start = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加空格(如果不是行首)
|
// 添加空格(如果不是行首且不是第一个单词)
|
||||||
if (current_width > 0 && !first_word) {
|
// 需要在不同 span 之间也添加空格
|
||||||
if (!current_line.spans.empty()) {
|
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_line.spans.back().text += " ";
|
||||||
current_width += 1;
|
current_width += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -503,7 +717,8 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
|
||||||
word_span.text = word;
|
word_span.text = word;
|
||||||
current_line.spans.push_back(word_span);
|
current_line.spans.push_back(word_span);
|
||||||
current_width += word_width;
|
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