Compare commits

...

11 commits

Author SHA1 Message Date
7ac0fc1c91 fix: Filter out script/style tags during DOM tree build
Some checks are pending
Build and Release / build (linux, ubuntu-latest) (push) Waiting to run
Build and Release / build (macos, macos-latest) (push) Waiting to run
Build and Release / release (push) Blocked by required conditions
Previously, script and style tags were only filtered during render,
but their text content (JavaScript code) was still in the DOM tree.
Now we skip these tags entirely during DOM tree construction,
resulting in much cleaner output for modern websites.
2025-12-27 18:24:23 +08:00
8d56a7b67b feat: Add persistent browsing history
- Implement HistoryManager for JSON persistence (~/.config/tut/history.json)
- Auto-record page visits with URL, title, and timestamp
- Update visit time when revisiting URLs (move to front)
- Limit to 1000 entries maximum
- Add :history command to view browsing history
- History entries are clickable links
- Add test_history test suite
2025-12-27 18:13:40 +08:00
3f7b627da5 docs: Update progress after code consolidation 2025-12-27 18:00:10 +08:00
2878b42d36 refactor: Consolidate v2 architecture into main codebase
- Merge browser_v2 implementation into browser.cpp
- Remove deprecated files: browser_v2.cpp/h, main_v2.cpp, text_renderer.cpp/h
- Simplify CMakeLists.txt to build single 'tut' executable
- Remove test HTML files no longer needed
- Add stb_image.h for image support
2025-12-27 17:59:05 +08:00
a469f79a1e test: Add comprehensive test suite for v2.0 release
- test_http_async: Async HTTP fetch, poll, and cancel tests
- test_html_parse: HTML parsing, link resolution, forms, images, Unicode
- test_bookmark: Add/remove/contains/persistence tests
2025-12-27 16:30:05 +08:00
e5276e0b4c docs: Update progress for Phase 6 async HTTP 2025-12-27 15:48:50 +08:00
18859eef47 feat: Add async HTTP requests with non-blocking loading
- Implement curl multi interface for async HTTP in HttpClient
- Add loading spinner animation during page load
- Support Esc key to cancel loading
- Non-blocking main loop with 50ms polling
- Loading state management (IDLE, LOADING_PAGE, LOADING_IMAGES)
- Preserve sync API for backward compatibility
2025-12-27 15:47:09 +08:00
584660a518 fix(ci): Add gumbo-parser dependency and build tut2
- Add gumbo-parser to macOS and Linux CI dependencies
- Update workflow to build and release tut2 (v2.0) instead of legacy tut
2025-12-27 15:38:48 +08:00
18f7804145 docs: Update progress tracking for v2.0.0-alpha 2025-12-27 15:35:44 +08:00
a4c95a6527 feat: Add bookmark management
- Add BookmarkManager class for bookmark CRUD operations
- Store bookmarks in JSON format at ~/.config/tut/bookmarks.json
- Add keyboard shortcuts: B (add), D (remove)
- Add :bookmarks/:bm command to view bookmark list
- Bookmarks page shows clickable links
- Auto-save on add/remove, auto-load on startup
2025-12-27 15:29:44 +08:00
c6b1a9ac41 feat: Add image ASCII art rendering support
- Add image data storage in DomNode for decoded images
- Collect image nodes in DocumentTree during parsing
- Download and decode images in browser_v2 before layout
- Render images as colored ASCII art using True Color
- Use stb_image for PNG/JPEG/GIF/BMP decoding (requires manual download)
- Fall back to placeholder for failed/missing images
2025-12-27 14:06:21 +08:00
30 changed files with 10501 additions and 2157 deletions

View file

@ -26,13 +26,13 @@ jobs:
if: matrix.os == 'macos-latest'
run: |
brew update
brew install cmake ncurses curl
brew install cmake ncurses curl gumbo-parser
- name: Install dependencies (Linux)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y cmake libncursesw5-dev libcurl4-openssl-dev
sudo apt-get install -y cmake libncursesw5-dev libcurl4-openssl-dev libgumbo-dev
- name: Configure CMake
run: |
@ -47,7 +47,7 @@ jobs:
- name: Rename binary with platform suffix
run: |
mv build/tut build/tut-${{ matrix.name }}
mv build/tut2 build/tut-${{ matrix.name }}
- name: Upload artifact
uses: actions/upload-artifact@v4

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.15)
project(TUT_v2 VERSION 2.0.0 LANGUAGES CXX)
project(TUT VERSION 2.0.0 LANGUAGES CXX)
# C++17标准
set(CMAKE_CXX_STANDARD 17)
@ -23,20 +23,44 @@ pkg_check_modules(GUMBO REQUIRED gumbo)
# 包含目录
include_directories(
${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/src/core
${CMAKE_SOURCE_DIR}/src/render
${CMAKE_SOURCE_DIR}/src/layout
${CMAKE_SOURCE_DIR}/src/parser
${CMAKE_SOURCE_DIR}/src/network
${CMAKE_SOURCE_DIR}/src/input
${CMAKE_SOURCE_DIR}/src/utils
${CURL_INCLUDE_DIRS}
${CURSES_INCLUDE_DIRS}
${GUMBO_INCLUDE_DIRS}
)
# ==================== Terminal 测试程序 ====================
# ==================== TUT 主程序 ====================
add_executable(tut
src/main.cpp
src/browser.cpp
src/http_client.cpp
src/input_handler.cpp
src/bookmark.cpp
src/history.cpp
src/render/terminal.cpp
src/render/renderer.cpp
src/render/layout.cpp
src/render/image.cpp
src/utils/unicode.cpp
src/dom_tree.cpp
src/html_parser.cpp
)
target_link_directories(tut PRIVATE
${GUMBO_LIBRARY_DIRS}
)
target_link_libraries(tut
${CURSES_LIBRARIES}
CURL::libcurl
${GUMBO_LIBRARIES}
)
# ==================== 测试程序 ====================
# Terminal 测试
add_executable(test_terminal
src/render/terminal.cpp
tests/test_terminal.cpp
@ -46,8 +70,7 @@ target_link_libraries(test_terminal
${CURSES_LIBRARIES}
)
# ==================== Renderer 测试程序 ====================
# Renderer 测试
add_executable(test_renderer
src/render/terminal.cpp
src/render/renderer.cpp
@ -59,8 +82,7 @@ target_link_libraries(test_renderer
${CURSES_LIBRARIES}
)
# ==================== Layout 测试程序 ====================
# Layout 测试
add_executable(test_layout
src/render/terminal.cpp
src/render/renderer.cpp
@ -81,50 +103,39 @@ target_link_libraries(test_layout
${GUMBO_LIBRARIES}
)
# ==================== TUT 2.0 主程序 ====================
add_executable(tut2
src/main_v2.cpp
src/browser_v2.cpp
# HTTP 异步测试
add_executable(test_http_async
src/http_client.cpp
src/input_handler.cpp
src/render/terminal.cpp
src/render/renderer.cpp
src/render/layout.cpp
src/render/image.cpp
src/utils/unicode.cpp
src/dom_tree.cpp
src/html_parser.cpp
tests/test_http_async.cpp
)
target_link_directories(tut2 PRIVATE
target_link_libraries(test_http_async
CURL::libcurl
)
# HTML 解析测试
add_executable(test_html_parse
src/html_parser.cpp
src/dom_tree.cpp
tests/test_html_parse.cpp
)
target_link_directories(test_html_parse PRIVATE
${GUMBO_LIBRARY_DIRS}
)
target_link_libraries(tut2
${CURSES_LIBRARIES}
CURL::libcurl
target_link_libraries(test_html_parse
${GUMBO_LIBRARIES}
)
# ==================== 旧版主程序 (向后兼容) ====================
add_executable(tut
src/main.cpp
src/browser.cpp
src/http_client.cpp
src/text_renderer.cpp
src/input_handler.cpp
src/dom_tree.cpp
src/html_parser.cpp
# 书签测试
add_executable(test_bookmark
src/bookmark.cpp
tests/test_bookmark.cpp
)
target_link_directories(tut PRIVATE
${GUMBO_LIBRARY_DIRS}
)
target_link_libraries(tut
${CURSES_LIBRARIES}
CURL::libcurl
${GUMBO_LIBRARIES}
# 历史记录测试
add_executable(test_history
src/history.cpp
tests/test_history.cpp
)

View file

@ -1,40 +1,53 @@
# TUT 2.0 - 下次继续从这里开始
## 当前位置
- **阶段**: Phase 4 - 图片支持 (基础完成)
- **进度**: 占位符显示已完成ASCII Art 渲染框架就绪
- **最后提交**: `d80d0a1 feat: Implement TUT 2.0 with new rendering architecture`
- **待推送**: 本地有 3 个提交未推送到 origin/main
- **阶段**: Phase 7 - 历史记录持久化 (已完成!)
- **进度**: 历史记录自动保存,支持 :history 命令查看
- **最后提交**: `feat: Add persistent browsing history`
## 立即可做的事
### 1. 推送代码到远程
```bash
git push origin main
```
### 1. 使用书签功能
- **B** - 添加当前页面到书签
- **D** - 从书签中移除当前页面
- **:bookmarks** 或 **:bm** - 查看书签列表
### 2. 启用完整图片支持 (PNG/JPEG)
```bash
# 下载 stb_image.h
curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \
-o src/utils/stb_image.h
书签存储在 `~/.config/tut/bookmarks.json`
# 重新编译
cmake --build build_v2
### 2. 查看历史记录
- **:history** 或 **:hist** - 查看浏览历史
# 编译后 ImageRenderer::load_from_memory() 将自动支持 PNG/JPEG/GIF/BMP
```
### 3. 在浏览器中集成图片渲染
需要在 `browser_v2.cpp` 中:
1. 收集页面中的所有 `<img>` 标签
2. 使用 `HttpClient::fetch_binary()` 下载图片
3. 调用 `ImageRenderer::load_from_memory()` 解码
4. 调用 `ImageRenderer::render()` 生成 ASCII Art
5. 将结果插入到布局中
历史记录存储在 `~/.config/tut/history.json`
## 已完成的功能清单
### Phase 7 - 历史记录持久化
- [x] HistoryEntry 数据结构 (URL, 标题, 访问时间)
- [x] JSON 持久化存储 (~/.config/tut/history.json)
- [x] 自动记录访问历史
- [x] 重复访问更新时间
- [x] 最大 1000 条记录限制
- [x] :history 命令查看历史页面
- [x] 历史链接可点击跳转
### Phase 6 - 异步HTTP
- [x] libcurl multi接口实现非阻塞请求
- [x] AsyncState状态管理 (IDLE/LOADING/COMPLETE/FAILED/CANCELLED)
- [x] start_async_fetch() 启动异步请求
- [x] poll_async() 非阻塞轮询
- [x] cancel_async() 取消请求
- [x] 加载动画 (旋转spinner: ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏)
- [x] Esc键取消加载
- [x] 主循环50ms轮询集成
### Phase 5 - 书签管理
- [x] 书签数据结构 (URL, 标题, 添加时间)
- [x] JSON 持久化存储 (~/.config/tut/bookmarks.json)
- [x] 添加书签 (B 键)
- [x] 删除书签 (D 键)
- [x] 书签列表页面 (:bookmarks 命令)
- [x] 书签链接可点击跳转
### Phase 4 - 图片支持
- [x] `<img>` 标签解析 (src, alt, width, height)
- [x] 图片占位符显示 `[alt text]``[Image: filename]`
@ -42,8 +55,9 @@ cmake --build build_v2
- [x] `HttpClient::fetch_binary()` 方法
- [x] `ImageRenderer` 类框架
- [x] PPM 格式内置解码
- [ ] stb_image.h 集成 (需手动下载)
- [ ] 浏览器中的图片下载和渲染
- [x] stb_image.h 集成 (PNG/JPEG/GIF/BMP 支持)
- [x] 浏览器中的图片下载和渲染
- [x] ASCII Art 彩色渲染 (True Color)
### Phase 3 - 性能优化
- [x] LRU 页面缓存 (20页, 5分钟过期)
@ -72,12 +86,14 @@ cmake --build build_v2
```
src/
├── browser_v2.cpp/h # 新架构浏览器 (pImpl模式)
├── main_v2.cpp # tut2 入口点
├── http_client.cpp/h # HTTP 客户端 (支持二进制)
├── browser.cpp/h # 主浏览器 (pImpl模式)
├── main.cpp # 程序入口点
├── http_client.cpp/h # HTTP 客户端 (支持二进制和异步)
├── dom_tree.cpp/h # DOM 树
├── html_parser.cpp/h # HTML 解析
├── input_handler.cpp/h # 输入处理
├── bookmark.cpp/h # 书签管理
├── history.cpp/h # 历史记录管理
├── render/
│ ├── terminal.cpp/h # 终端抽象 (ncurses)
│ ├── renderer.cpp/h # FrameBuffer + 差分渲染
@ -87,29 +103,36 @@ src/
│ └── decorations.h # Unicode 装饰字符
└── utils/
├── unicode.cpp/h # Unicode 处理
└── stb_image.h # [需下载] 图片解码库
└── stb_image.h # 图片解码库
tests/
├── test_terminal.cpp # Terminal 测试
├── test_renderer.cpp # Renderer 测试
└── test_layout.cpp # Layout + 图片占位符测试
├── test_layout.cpp # Layout + 图片占位符测试
├── test_http_async.cpp # HTTP 异步测试
├── test_html_parse.cpp # HTML 解析测试
├── test_bookmark.cpp # 书签测试
└── test_history.cpp # 历史记录测试
```
## 构建与运行
```bash
# 构建
cmake -B build_v2 -S . -DCMAKE_BUILD_TYPE=Debug
cmake --build build_v2
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
cmake --build build
# 运行
./build_v2/tut2 # 显示帮助
./build_v2/tut2 https://example.com # 打开网页
./build/tut # 显示帮助
./build/tut https://example.com # 打开网页
# 测试
./build_v2/test_terminal # 终端测试
./build_v2/test_renderer # 渲染测试
./build_v2/test_layout # 布局+图片测试 (按回车进入交互模式)
./build/test_terminal # 终端测试
./build/test_renderer # 渲染测试
./build/test_layout # 布局+图片测试
./build/test_http_async # HTTP异步测试
./build/test_html_parse # HTML解析测试
./build/test_bookmark # 书签测试
```
## 快捷键
@ -125,20 +148,39 @@ cmake --build build_v2
| / | 搜索 |
| n/N | 下一个/上一个匹配 |
| r | 刷新 (跳过缓存) |
| B | 添加书签 |
| D | 删除书签 |
| :o URL | 打开URL |
| :bookmarks | 查看书签 |
| :history | 查看历史 |
| :q | 退出 |
| ? | 帮助 |
| Esc | 取消加载 |
## 下一步功能优先级
1. **完成图片 ASCII Art 渲染** - 下载 stb_image.h 并集成到浏览器
2. **书签管理** - 添加/删除书签,书签列表页面,持久化存储
3. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求
4. **更多表单交互** - 文本输入编辑,下拉选择
1. **更多表单交互** - 文本输入编辑,下拉选择
2. **图片缓存** - 避免重复下载相同图片
3. **异步图片加载** - 图片也使用异步加载
4. **Cookie 支持** - 保存和发送 Cookie
## 恢复对话时说
> "继续TUT 2.0开发"
## Git 信息
- **当前标签**: `v2.0.0-alpha`
- **远程仓库**: https://github.com/m1ngsama/TUT
```bash
# 恢复开发
git clone https://github.com/m1ngsama/TUT.git
cd TUT
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
cmake --build build
./build/tut
```
---
更新时间: 2025-12-26 15:00
更新时间: 2025-12-27

248
src/bookmark.cpp Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,20 @@
#include "http_client.h"
#include "html_parser.h"
#include "text_renderer.h"
#include "input_handler.h"
#include "render/terminal.h"
#include "render/renderer.h"
#include "render/layout.h"
#include <string>
#include <vector>
#include <memory>
/**
* Browser - TUT
*
* 使 Terminal + FrameBuffer + Renderer + LayoutEngine
* True Color, Unicode,
*/
class Browser {
public:
Browser();

View file

@ -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;
}

View file

@ -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;
};

View file

@ -48,7 +48,12 @@ bool DomNode::is_block_element() const {
tag_name == "pre" || tag_name == "hr" ||
tag_name == "table" || tag_name == "tr" ||
tag_name == "th" || tag_name == "td" ||
tag_name == "form" || tag_name == "fieldset";
tag_name == "tbody" || tag_name == "thead" ||
tag_name == "tfoot" || tag_name == "caption" ||
tag_name == "form" || tag_name == "fieldset" ||
tag_name == "figure" || tag_name == "figcaption" ||
tag_name == "details" || tag_name == "summary" ||
tag_name == "center" || tag_name == "address";
}
}
@ -91,6 +96,11 @@ bool DomNode::should_render() const {
std::string DomNode::get_all_text() const {
std::string result;
// 过滤不应该提取文本的元素
if (!should_render()) {
return "";
}
if (node_type == NodeType::TEXT) {
result = text_content;
} else {
@ -136,7 +146,7 @@ DocumentTree DomTreeBuilder::build(const std::string& html, const std::string& b
// 2. 转换为DomNode树
DocumentTree tree;
tree.url = base_url;
tree.root = convert_node(output->root, tree.links, tree.form_fields, base_url);
tree.root = convert_node(output->root, tree.links, tree.form_fields, tree.images, base_url);
// 3. 提取标题
if (tree.root) {
@ -153,6 +163,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
GumboNode* gumbo_node,
std::vector<Link>& links,
std::vector<DomNode*>& form_fields,
std::vector<DomNode*>& images,
const std::string& base_url
) {
if (!gumbo_node) return nullptr;
@ -167,6 +178,12 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
node->tag_name = gumbo_normalized_tagname(element.tag);
node->element_type = map_gumbo_tag_to_element_type(element.tag);
// 跳过 script、style 等不需要渲染的标签(包括其所有子节点)
if (element.tag == GUMBO_TAG_SCRIPT || element.tag == GUMBO_TAG_STYLE ||
element.tag == GUMBO_TAG_NOSCRIPT || element.tag == GUMBO_TAG_TEMPLATE) {
return nullptr;
}
// Assign current form ID to children
node->form_id = g_current_form_id;
@ -282,6 +299,11 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
if (height_attr && height_attr->value) {
try { node->img_height = std::stoi(height_attr->value); } catch (...) {}
}
// 添加到图片列表(用于后续下载)
if (!node->img_src.empty()) {
images.push_back(node.get());
}
}
@ -334,6 +356,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
static_cast<GumboNode*>(children->data[i]),
links,
form_fields,
images,
base_url
);
if (child) {
@ -371,6 +394,7 @@ std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
static_cast<GumboNode*>(doc.children.data[i]),
links,
form_fields,
images,
base_url
);
if (child) {

View file

@ -1,6 +1,7 @@
#pragma once
#include "html_parser.h"
#include "render/image.h"
#include <string>
#include <vector>
#include <memory>
@ -40,6 +41,7 @@ struct DomNode {
std::string alt_text; // 图片alt文本
int img_width = -1; // 图片宽度 (-1表示未指定)
int img_height = -1; // 图片高度 (-1表示未指定)
tut::ImageData image_data; // 解码后的图片数据
// 表格属性
bool is_table_header = false;
@ -68,6 +70,7 @@ struct DocumentTree {
std::unique_ptr<DomNode> root;
std::vector<Link> links; // 全局链接列表
std::vector<DomNode*> form_fields; // 全局表单字段列表 (非拥有指针)
std::vector<DomNode*> images; // 全局图片列表 (非拥有指针)
std::string title;
std::string url;
};
@ -87,6 +90,7 @@ private:
GumboNode* gumbo_node,
std::vector<Link>& links,
std::vector<DomNode*>& form_fields,
std::vector<DomNode*>& images,
const std::string& base_url
);

217
src/history.cpp Normal file
View 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
View 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

View file

@ -25,8 +25,15 @@ public:
bool follow_redirects;
std::string cookie_file;
// 异步请求相关
CURLM* multi_handle = nullptr;
CURL* async_easy = nullptr;
AsyncState async_state = AsyncState::IDLE;
std::string async_response_body;
HttpResponse async_result;
Impl() : timeout(30),
user_agent("TUT-Browser/1.0 (Terminal User Interface Browser)"),
user_agent("TUT-Browser/2.0 (Terminal User Interface Browser)"),
follow_redirects(true) {
curl = curl_easy_init();
if (!curl) {
@ -36,13 +43,59 @@ public:
curl_easy_setopt(curl, CURLOPT_COOKIEFILE, "");
// Enable automatic decompression of supported encodings (gzip, deflate, etc.)
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
// 初始化multi handle用于异步请求
multi_handle = curl_multi_init();
if (!multi_handle) {
throw std::runtime_error("Failed to initialize CURL multi handle");
}
}
~Impl() {
// 清理异步请求
cleanup_async();
if (multi_handle) {
curl_multi_cleanup(multi_handle);
}
if (curl) {
curl_easy_cleanup(curl);
}
}
void cleanup_async() {
if (async_easy) {
curl_multi_remove_handle(multi_handle, async_easy);
curl_easy_cleanup(async_easy);
async_easy = nullptr;
}
async_state = AsyncState::IDLE;
async_response_body.clear();
}
void setup_easy_handle(CURL* handle, const std::string& url) {
curl_easy_setopt(handle, CURLOPT_URL, url.c_str());
curl_easy_setopt(handle, CURLOPT_TIMEOUT, timeout);
curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT, 10L);
curl_easy_setopt(handle, CURLOPT_USERAGENT, user_agent.c_str());
if (follow_redirects) {
curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(handle, CURLOPT_MAXREDIRS, 10L);
}
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L);
if (!cookie_file.empty()) {
curl_easy_setopt(handle, CURLOPT_COOKIEFILE, cookie_file.c_str());
curl_easy_setopt(handle, CURLOPT_COOKIEJAR, cookie_file.c_str());
} else {
curl_easy_setopt(handle, CURLOPT_COOKIEFILE, "");
}
curl_easy_setopt(handle, CURLOPT_ACCEPT_ENCODING, "");
}
};
HttpClient::HttpClient() : pImpl(std::make_unique<Impl>()) {}
@ -298,3 +351,105 @@ void HttpClient::set_follow_redirects(bool follow) {
void HttpClient::enable_cookies(const std::string& cookie_file) {
pImpl->cookie_file = cookie_file;
}
// ==================== 异步请求实现 ====================
void HttpClient::start_async_fetch(const std::string& url) {
// 如果有正在进行的请求,先取消
if (pImpl->async_easy) {
cancel_async();
}
// 创建新的easy handle
pImpl->async_easy = curl_easy_init();
if (!pImpl->async_easy) {
pImpl->async_state = AsyncState::FAILED;
pImpl->async_result.error_message = "Failed to create CURL handle";
return;
}
// 配置请求
pImpl->setup_easy_handle(pImpl->async_easy, url);
// 设置写回调
pImpl->async_response_body.clear();
curl_easy_setopt(pImpl->async_easy, CURLOPT_WRITEFUNCTION, write_callback);
curl_easy_setopt(pImpl->async_easy, CURLOPT_WRITEDATA, &pImpl->async_response_body);
// 添加到multi handle
curl_multi_add_handle(pImpl->multi_handle, pImpl->async_easy);
pImpl->async_state = AsyncState::LOADING;
pImpl->async_result = HttpResponse{}; // 重置结果
}
AsyncState HttpClient::poll_async() {
if (pImpl->async_state != AsyncState::LOADING) {
return pImpl->async_state;
}
// 执行非阻塞的multi perform
int still_running = 0;
CURLMcode mc = curl_multi_perform(pImpl->multi_handle, &still_running);
if (mc != CURLM_OK) {
pImpl->async_result.error_message = curl_multi_strerror(mc);
pImpl->async_state = AsyncState::FAILED;
pImpl->cleanup_async();
return pImpl->async_state;
}
// 检查是否有完成的请求
int msgs_left = 0;
CURLMsg* msg;
while ((msg = curl_multi_info_read(pImpl->multi_handle, &msgs_left))) {
if (msg->msg == CURLMSG_DONE) {
CURL* easy = msg->easy_handle;
CURLcode result = msg->data.result;
if (result == CURLE_OK) {
// 获取响应信息
long http_code = 0;
curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &http_code);
pImpl->async_result.status_code = static_cast<int>(http_code);
char* content_type = nullptr;
curl_easy_getinfo(easy, CURLINFO_CONTENT_TYPE, &content_type);
if (content_type) {
pImpl->async_result.content_type = content_type;
}
pImpl->async_result.body = std::move(pImpl->async_response_body);
pImpl->async_state = AsyncState::COMPLETE;
} else {
pImpl->async_result.error_message = curl_easy_strerror(result);
pImpl->async_state = AsyncState::FAILED;
}
// 清理handle但保留状态供获取结果
curl_multi_remove_handle(pImpl->multi_handle, pImpl->async_easy);
curl_easy_cleanup(pImpl->async_easy);
pImpl->async_easy = nullptr;
}
}
return pImpl->async_state;
}
HttpResponse HttpClient::get_async_result() {
HttpResponse result = std::move(pImpl->async_result);
pImpl->async_result = HttpResponse{};
pImpl->async_state = AsyncState::IDLE;
return result;
}
void HttpClient::cancel_async() {
if (pImpl->async_easy) {
pImpl->cleanup_async();
pImpl->async_state = AsyncState::CANCELLED;
}
}
bool HttpClient::is_async_active() const {
return pImpl->async_state == AsyncState::LOADING;
}

View file

@ -5,6 +5,15 @@
#include <cstdint>
#include <memory>
// 异步请求状态
enum class AsyncState {
IDLE, // 无活跃请求
LOADING, // 请求进行中
COMPLETE, // 请求成功完成
FAILED, // 请求失败
CANCELLED // 请求被取消
};
struct HttpResponse {
int status_code;
std::string body;
@ -36,10 +45,20 @@ public:
HttpClient();
~HttpClient();
// 同步请求接口
HttpResponse fetch(const std::string& url);
BinaryResponse fetch_binary(const std::string& url);
HttpResponse post(const std::string& url, const std::string& data,
const std::string& content_type = "application/x-www-form-urlencoded");
// 异步请求接口
void start_async_fetch(const std::string& url);
AsyncState poll_async(); // 非阻塞轮询,返回当前状态
HttpResponse get_async_result(); // 获取结果并重置状态
void cancel_async(); // 取消当前异步请求
bool is_async_active() const; // 是否有活跃的异步请求
// 配置
void set_timeout(long timeout_seconds);
void set_user_agent(const std::string& user_agent);
void set_follow_redirects(bool follow);

View file

@ -174,6 +174,12 @@ public:
case '?':
result.action = Action::HELP;
break;
case 'B':
result.action = Action::ADD_BOOKMARK;
break;
case 'D':
result.action = Action::REMOVE_BOOKMARK;
break;
default:
buffer.clear();
break;
@ -201,6 +207,10 @@ public:
result.action = Action::OPEN_URL;
result.text = command.substr(space_pos + 1);
}
} else if (command == "bookmarks" || command == "bm" || command == "b") {
result.action = Action::SHOW_BOOKMARKS;
} else if (command == "history" || command == "hist" || command == "hi") {
result.action = Action::SHOW_HISTORY;
} else if (!command.empty() && std::isdigit(command[0])) {
try {
result.action = Action::GOTO_LINE;

View file

@ -38,7 +38,11 @@ enum class Action {
QUIT,
HELP,
SET_MARK, // Set a mark (m + letter)
GOTO_MARK // Jump to mark (' + letter)
GOTO_MARK, // Jump to mark (' + letter)
ADD_BOOKMARK, // Add current page to bookmarks (B)
REMOVE_BOOKMARK, // Remove current page from bookmarks (D)
SHOW_BOOKMARKS, // Show bookmarks page (:bookmarks)
SHOW_HISTORY // Show history page (:history)
};
struct InputResult {

View file

@ -4,7 +4,7 @@
void print_usage(const char* prog_name) {
std::cout << "TUT - Terminal User Interface Browser\n"
<< "A vim-style terminal web browser for comfortable reading\n\n"
<< "A vim-style terminal web browser with True Color support\n\n"
<< "Usage: " << prog_name << " [URL]\n\n"
<< "If no URL is provided, the browser will start with a help page.\n\n"
<< "Examples:\n"
@ -44,4 +44,3 @@ int main(int argc, char* argv[]) {
return 0;
}

View file

@ -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;
}

View file

@ -77,20 +77,6 @@ void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<La
return;
}
// 处理容器元素html, body, div, form等- 递归处理子节点
if (node->tag_name == "html" || node->tag_name == "body" ||
node->tag_name == "head" || node->tag_name == "main" ||
node->tag_name == "article" || node->tag_name == "section" ||
node->tag_name == "div" || node->tag_name == "header" ||
node->tag_name == "footer" || node->tag_name == "nav" ||
node->tag_name == "aside" || node->tag_name == "form" ||
node->tag_name == "fieldset") {
for (const auto& child : node->children) {
layout_node(child.get(), ctx, blocks);
}
return;
}
// 处理表单内联元素
if (node->element_type == ElementType::INPUT ||
node->element_type == ElementType::BUTTON ||
@ -106,10 +92,86 @@ void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<La
return;
}
// 处理块级元素
if (node->is_block_element()) {
layout_block_element(node, ctx, blocks);
return;
}
// 处理链接 - 当链接单独出现时(不在段落内),创建一个单独的块
if (node->element_type == ElementType::LINK && node->link_index >= 0) {
// 检查链接是否有可见文本
std::string link_text = node->get_all_text();
// 去除空白
size_t start = link_text.find_first_not_of(" \t\n\r");
size_t end = link_text.find_last_not_of(" \t\n\r");
if (start != std::string::npos && end != std::string::npos) {
link_text = link_text.substr(start, end - start + 1);
} else {
link_text = "";
}
if (!link_text.empty()) {
LayoutBlock block;
block.type = ElementType::PARAGRAPH;
block.margin_top = 0;
block.margin_bottom = 0;
LayoutLine line;
line.indent = MARGIN_LEFT;
StyledSpan span;
span.text = link_text;
span.fg = colors::LINK_FG;
span.attrs = ATTR_UNDERLINE;
span.link_index = node->link_index;
line.spans.push_back(span);
block.lines.push_back(line);
blocks.push_back(block);
}
return;
}
// 处理容器元素 - 递归处理子节点
// 这包括html, body, div, table, span, center 等所有容器类元素
if (node->node_type == NodeType::ELEMENT && !node->children.empty()) {
for (const auto& child : node->children) {
layout_node(child.get(), ctx, blocks);
}
return;
}
// 处理独立文本节点
if (node->node_type == NodeType::TEXT && !node->text_content.empty()) {
std::string text = node->text_content;
// 去除首尾空白
size_t start = text.find_first_not_of(" \t\n\r");
size_t end = text.find_last_not_of(" \t\n\r");
if (start != std::string::npos && end != std::string::npos) {
text = text.substr(start, end - start + 1);
} else {
return; // 空白文本,跳过
}
if (!text.empty()) {
LayoutBlock block;
block.type = ElementType::TEXT;
block.margin_top = 0;
block.margin_bottom = 0;
std::vector<StyledSpan> spans;
StyledSpan span;
span.text = text;
span.fg = colors::FG_PRIMARY;
spans.push_back(span);
block.lines = wrap_text(spans, content_width_, MARGIN_LEFT);
if (!block.lines.empty()) {
blocks.push_back(block);
}
}
}
// 内联元素在块级元素内部处理
}
void LayoutEngine::layout_block_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks) {
@ -391,10 +453,88 @@ void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, s
block.margin_top = 0;
block.margin_bottom = 1;
// 检查是否有解码后的图片数据
if (node->image_data.is_valid()) {
// 渲染 ASCII Art
ImageRenderer renderer;
renderer.set_mode(ImageRenderer::Mode::BLOCKS);
renderer.set_color_enabled(true);
// 计算图片最大尺寸(留出左边距)
int max_width = content_width_;
int max_height = 30; // 限制高度
// 如果节点指定了尺寸,使用更小的值
if (node->img_width > 0) {
max_width = std::min(max_width, node->img_width);
}
if (node->img_height > 0) {
max_height = std::min(max_height, node->img_height / 2); // 考虑字符高宽比
}
AsciiImage ascii = renderer.render(node->image_data, max_width, max_height);
if (!ascii.lines.empty()) {
for (size_t i = 0; i < ascii.lines.size(); ++i) {
LayoutLine line;
line.indent = MARGIN_LEFT;
// 将每一行作为一个 span
// 但由于颜色可能不同,需要逐字符处理
const std::string& line_text = ascii.lines[i];
const std::vector<uint32_t>& line_colors = ascii.colors[i];
// 为了效率,尝试合并相同颜色的字符
size_t pos = 0;
while (pos < line_text.size()) {
// 获取当前字符的字节数UTF-8
int char_bytes = 1;
unsigned char c = line_text[pos];
if ((c & 0x80) == 0) {
char_bytes = 1;
} else if ((c & 0xE0) == 0xC0) {
char_bytes = 2;
} else if ((c & 0xF0) == 0xE0) {
char_bytes = 3;
} else if ((c & 0xF8) == 0xF0) {
char_bytes = 4;
}
// 获取颜色索引(基于显示宽度位置)
size_t color_idx = 0;
for (size_t j = 0; j < pos; ) {
unsigned char ch = line_text[j];
int bytes = 1;
if ((ch & 0x80) == 0) bytes = 1;
else if ((ch & 0xE0) == 0xC0) bytes = 2;
else if ((ch & 0xF0) == 0xE0) bytes = 3;
else if ((ch & 0xF8) == 0xF0) bytes = 4;
color_idx++;
j += bytes;
}
uint32_t color = (color_idx < line_colors.size()) ? line_colors[color_idx] : colors::FG_PRIMARY;
StyledSpan span;
span.text = line_text.substr(pos, char_bytes);
span.fg = color;
span.attrs = ATTR_NONE;
line.spans.push_back(span);
pos += char_bytes;
}
block.lines.push_back(line);
}
blocks.push_back(block);
return;
}
}
// 回退到占位符
LayoutLine line;
line.indent = MARGIN_LEFT;
// 生成图片占位符
std::string placeholder = make_image_placeholder(node->alt_text, node->img_src);
StyledSpan span;
@ -407,6 +547,41 @@ void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, s
blocks.push_back(block);
}
// 辅助函数:检查是否需要在两个文本之间添加空格
static bool needs_space_between(const std::string& prev, const std::string& next) {
if (prev.empty() || next.empty()) return false;
char last_char = prev.back();
char first_char = next.front();
// 检查前一个是否以空白结尾
bool prev_ends_with_space = (last_char == ' ' || last_char == '\t' ||
last_char == '\n' || last_char == '\r');
if (prev_ends_with_space) return false;
// 检查当前是否以空白开头
bool curr_starts_with_space = (first_char == ' ' || first_char == '\t' ||
first_char == '\n' || first_char == '\r');
if (curr_starts_with_space) return false;
// 检查是否是标点符号(不需要空格)
bool is_punct = (first_char == '.' || first_char == ',' ||
first_char == '!' || first_char == '?' ||
first_char == ':' || first_char == ';' ||
first_char == ')' || first_char == ']' ||
first_char == '}' || first_char == '|' ||
first_char == '\'' || first_char == '"');
if (is_punct) return false;
// 检查前一个字符是否是特殊符号(不需要空格)
bool prev_is_open = (last_char == '(' || last_char == '[' ||
last_char == '{' || last_char == '\'' ||
last_char == '"');
if (prev_is_open) return false;
return true;
}
void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
if (!node) return;
@ -424,10 +599,21 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
int link_idx = node->link_index;
// 递归处理子节点
for (const auto& child : node->children) {
for (size_t i = 0; i < node->children.size(); ++i) {
const auto& child = node->children[i];
if (child->node_type == NodeType::TEXT) {
std::string text = child->text_content;
// 检查是否需要在之前的内容和当前内容之间添加空格
if (!spans.empty() && !text.empty()) {
if (needs_space_between(spans.back().text, text)) {
spans.back().text += " ";
}
}
StyledSpan span;
span.text = child->text_content;
span.text = text;
span.fg = fg;
span.attrs = attrs;
span.link_index = link_idx;
@ -438,6 +624,16 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
spans.push_back(span);
} else if (!child->is_block_element()) {
// 获取子节点的全部文本,用于检查是否需要空格
std::string child_text = child->get_all_text();
// 在递归调用前检查空格
if (!spans.empty() && !child_text.empty()) {
if (needs_space_between(spans.back().text, child_text)) {
spans.back().text += " ";
}
}
collect_inline_content(child.get(), ctx, spans);
}
}
@ -447,8 +643,17 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
void LayoutEngine::layout_text(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
if (!node || node->text_content.empty()) return;
std::string text = node->text_content;
// 检查是否需要在之前的内容和当前内容之间添加空格
if (!spans.empty() && !text.empty()) {
if (needs_space_between(spans.back().text, text)) {
spans.back().text += " ";
}
}
StyledSpan span;
span.text = node->text_content;
span.text = text;
span.fg = colors::FG_PRIMARY;
if (ctx.in_blockquote) {
@ -468,12 +673,13 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
LayoutLine current_line;
current_line.indent = indent;
size_t current_width = 0;
bool is_line_start = true; // 整行的开始标记
for (const auto& span : spans) {
// 分词处理
std::istringstream iss(span.text);
std::string word;
bool first_word = true;
bool first_word_in_span = true;
while (iss >> word) {
size_t word_width = Unicode::display_width(word);
@ -487,12 +693,20 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
current_line = LayoutLine();
current_line.indent = indent;
current_width = 0;
first_word = true;
is_line_start = true;
}
// 添加空格(如果不是行首)
if (current_width > 0 && !first_word) {
if (!current_line.spans.empty()) {
// 添加空格(如果不是行首且不是第一个单词)
// 需要在不同 span 之间也添加空格
if (!is_line_start) {
// 检查是否需要空格(避免在标点前加空格)
char first_char = word.front();
bool is_punct = (first_char == '.' || first_char == ',' ||
first_char == '!' || first_char == '?' ||
first_char == ':' || first_char == ';' ||
first_char == ')' || first_char == ']' ||
first_char == '}' || first_char == '|');
if (!is_punct && !current_line.spans.empty()) {
current_line.spans.back().text += " ";
current_width += 1;
}
@ -503,7 +717,8 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
word_span.text = word;
current_line.spans.push_back(word_span);
current_width += word_width;
first_word = false;
is_line_start = false;
first_word_in_span = false;
}
}

View file

@ -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);
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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>

View file

@ -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
View 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
View 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
View 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
View 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;
}