mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 00:54:05 +00:00
refactor: Clean up old v1 files and fix LinkInfo type issues
Cleanup: - Remove all legacy v1 ncurses-based source files - Remove old documentation files (NEXT_STEPS.md, TESTING.md, etc.) - Remove deprecated test files and scripts - Update README.md for FTXUI architecture Build fixes: - Create src/core/types.hpp for shared LinkInfo struct - Fix incomplete type errors in html_renderer and content_view - Update includes to use types.hpp instead of forward declarations - All tests now compile successfully Binary: 827KB (well under 1MB goal) Build: Clean compilation with no warnings Tests: All unit and integration tests build successfully
This commit is contained in:
parent
fffb3c6756
commit
eea499e56e
44 changed files with 193 additions and 9667 deletions
|
|
@ -1,76 +0,0 @@
|
|||
# Quick Link Navigation Guide
|
||||
|
||||
The browser now supports vim-style quick navigation to links!
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Visual Link Numbers
|
||||
All links are displayed inline in the text with numbers like `[0]`, `[1]`, `[2]`, etc.
|
||||
- Links are shown with yellow color and underline
|
||||
- Active link (selected with Tab) has yellow background
|
||||
|
||||
### 2. Quick Navigation Methods
|
||||
|
||||
#### Method 1: Number + Enter
|
||||
Type a number and press Enter to jump to that link:
|
||||
```
|
||||
3<Enter> - Jump to link [3]
|
||||
10<Enter> - Jump to link [10]
|
||||
```
|
||||
|
||||
#### Method 2: 'f' command (follow)
|
||||
Press `f` followed by a number to immediately open that link:
|
||||
```
|
||||
f3 - Open link [3] directly
|
||||
f10 - Open link [10] directly
|
||||
```
|
||||
|
||||
Or type the number first:
|
||||
```
|
||||
3f - Open link [3] directly
|
||||
10f - Open link [10] directly
|
||||
```
|
||||
|
||||
#### Method 3: Traditional Tab navigation (still works)
|
||||
```
|
||||
Tab - Next link
|
||||
Shift-Tab/T - Previous link
|
||||
Enter - Follow current highlighted link
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Given a page with these links:
|
||||
- "Google[0]"
|
||||
- "GitHub[1]"
|
||||
- "Wikipedia[2]"
|
||||
|
||||
You can:
|
||||
- Press `1<Enter>` to select GitHub link
|
||||
- Press `f2` to immediately open Wikipedia
|
||||
- Press `Tab` twice then `Enter` to open Wikipedia
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Test with a real website
|
||||
./tut https://example.com
|
||||
|
||||
# View help
|
||||
./tut
|
||||
# Press ? for help
|
||||
```
|
||||
|
||||
## Key Bindings Summary
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `[N]<Enter>` | Jump to link N |
|
||||
| `f[N]` or `[N]f` | Open link N directly |
|
||||
| `Tab` | Next link |
|
||||
| `Shift-Tab` / `T` | Previous link |
|
||||
| `Enter` | Follow current link |
|
||||
| `h` | Go back |
|
||||
| `l` | Go forward |
|
||||
|
||||
All standard vim navigation keys (j/k, gg/G, /, n/N) still work as before!
|
||||
28
Makefile
28
Makefile
|
|
@ -1,28 +0,0 @@
|
|||
# Simple Makefile wrapper for CMake build system
|
||||
# Follows Unix convention: simple interface to underlying build
|
||||
|
||||
BUILD_DIR = build
|
||||
TARGET = tut
|
||||
|
||||
.PHONY: all clean install test help
|
||||
|
||||
all:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@cd $(BUILD_DIR) && cmake .. && cmake --build .
|
||||
@cp $(BUILD_DIR)/$(TARGET) .
|
||||
|
||||
clean:
|
||||
@rm -rf $(BUILD_DIR) $(TARGET)
|
||||
|
||||
install: all
|
||||
@install -m 755 $(TARGET) /usr/local/bin/
|
||||
|
||||
test: all
|
||||
@./$(TARGET) https://example.com
|
||||
|
||||
help:
|
||||
@echo "TUT Browser - Simple make targets"
|
||||
@echo " make - Build the browser"
|
||||
@echo " make clean - Remove build artifacts"
|
||||
@echo " make install - Install to /usr/local/bin"
|
||||
@echo " make test - Quick test run"
|
||||
266
NEXT_STEPS.md
266
NEXT_STEPS.md
|
|
@ -1,266 +0,0 @@
|
|||
# TUT 2.0 - 下次继续从这里开始
|
||||
|
||||
## 当前位置
|
||||
- **阶段**: Phase 10 - 异步图片加载 (已完成!)
|
||||
- **进度**: 多并发图片下载、渐进式渲染、非阻塞UI
|
||||
- **最后提交**: `feat: Add async image loading with progressive rendering`
|
||||
|
||||
## 立即可做的事
|
||||
|
||||
### 1. 使用书签功能
|
||||
- **B** - 添加当前页面到书签
|
||||
- **D** - 从书签中移除当前页面
|
||||
- **:bookmarks** 或 **:bm** - 查看书签列表
|
||||
|
||||
书签存储在 `~/.config/tut/bookmarks.json`
|
||||
|
||||
### 2. 查看历史记录
|
||||
- **:history** 或 **:hist** - 查看浏览历史
|
||||
|
||||
历史记录存储在 `~/.config/tut/history.json`
|
||||
|
||||
### 3. 表单交互
|
||||
- **i** - 聚焦到第一个表单字段
|
||||
- **Tab** - 下一个表单字段
|
||||
- **Shift+Tab** - 上一个表单字段
|
||||
- **Enter** - 激活字段(文本输入/下拉选择/复选框)
|
||||
- 在文本输入模式下:
|
||||
- 输入文字实时更新
|
||||
- **Enter** 或 **Esc** - 退出编辑模式
|
||||
- 在下拉选择模式下:
|
||||
- **j/k** 或 **↓/↑** - 导航选项
|
||||
- **Enter** - 选择当前选项
|
||||
- **Esc** - 取消选择
|
||||
|
||||
## 已完成的功能清单
|
||||
|
||||
### Phase 10 - 异步图片加载
|
||||
- [x] 异步二进制下载接口 (HttpClient)
|
||||
- [x] 图片下载队列管理
|
||||
- [x] 多并发下载 (最多3张图片同时下载)
|
||||
- [x] 渐进式渲染 (图片下载完立即显示)
|
||||
- [x] 非阻塞UI (下载时可正常浏览)
|
||||
- [x] 实时进度显示
|
||||
- [x] Esc取消图片加载
|
||||
- [x] 保留图片缓存系统兼容
|
||||
|
||||
### Phase 9 - 性能优化和测试工具
|
||||
- [x] 图片 LRU 缓存 (100张,10分钟过期)
|
||||
- [x] 缓存命中统计显示
|
||||
- [x] 交互式测试脚本 (test_browser.sh)
|
||||
- [x] 完整测试指南 (TESTING.md)
|
||||
- [x] 帮助文档更新(包含所有新功能)
|
||||
- [x] 测试清单和成功标准
|
||||
|
||||
### Phase 8 - 表单交互增强
|
||||
- [x] 文本输入框编辑
|
||||
- [x] 实时文本编辑和预览
|
||||
- [x] Tab/Shift+Tab 字段导航
|
||||
- [x] 复选框切换
|
||||
- [x] 下拉选择(SELECT/OPTION)
|
||||
- [x] SELECT 选项解析和存储
|
||||
- [x] j/k 导航选项
|
||||
- [x] 状态栏显示 INSERT/SELECT 模式
|
||||
- [x] 'i' 键聚焦首个表单字段
|
||||
|
||||
### 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]`
|
||||
- [x] `BinaryResponse` 结构体
|
||||
- [x] `HttpClient::fetch_binary()` 方法
|
||||
- [x] `ImageRenderer` 类框架
|
||||
- [x] PPM 格式内置解码
|
||||
- [x] stb_image.h 集成 (PNG/JPEG/GIF/BMP 支持)
|
||||
- [x] 浏览器中的图片下载和渲染
|
||||
- [x] ASCII Art 彩色渲染 (True Color)
|
||||
|
||||
### Phase 3 - 性能优化
|
||||
- [x] LRU 页面缓存 (20页, 5分钟过期)
|
||||
- [x] 差分渲染 (只更新变化的单元格)
|
||||
- [x] 批量输出优化
|
||||
- [x] 加载状态指示
|
||||
|
||||
### Phase 2 - 交互增强
|
||||
- [x] 搜索功能 (/, n/N)
|
||||
- [x] 搜索高亮
|
||||
- [x] Tab 切换链接时自动滚动
|
||||
- [x] 窗口大小动态调整
|
||||
- [x] 表单渲染 (input, button, checkbox, radio, select)
|
||||
- [x] POST 表单提交
|
||||
|
||||
### Phase 1 - 核心架构
|
||||
- [x] Terminal 抽象层 (raw mode, True Color)
|
||||
- [x] FrameBuffer 双缓冲
|
||||
- [x] Renderer 差分渲染
|
||||
- [x] LayoutEngine 布局引擎
|
||||
- [x] DocumentRenderer 文档渲染
|
||||
- [x] Unicode 宽度计算 (CJK 支持)
|
||||
- [x] 温暖护眼配色方案
|
||||
|
||||
## 代码结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── 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 + 差分渲染
|
||||
│ ├── layout.cpp/h # 布局引擎 + 文档渲染
|
||||
│ ├── image.cpp/h # 图片渲染器 (ASCII Art)
|
||||
│ ├── colors.h # 配色方案定义
|
||||
│ └── decorations.h # Unicode 装饰字符
|
||||
└── utils/
|
||||
├── unicode.cpp/h # Unicode 处理
|
||||
└── stb_image.h # 图片解码库
|
||||
|
||||
tests/
|
||||
├── test_terminal.cpp # Terminal 测试
|
||||
├── test_renderer.cpp # Renderer 测试
|
||||
├── test_layout.cpp # Layout + 图片占位符测试
|
||||
├── test_http_async.cpp # HTTP 异步测试
|
||||
├── test_html_parse.cpp # HTML 解析测试
|
||||
├── test_bookmark.cpp # 书签测试
|
||||
└── test_history.cpp # 历史记录测试
|
||||
```
|
||||
|
||||
## 构建与运行
|
||||
|
||||
```bash
|
||||
# 构建
|
||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build
|
||||
|
||||
# 运行
|
||||
./build/tut # 显示帮助
|
||||
./build/tut https://example.com # 打开网页
|
||||
|
||||
# 测试
|
||||
./build/test_terminal # 终端测试
|
||||
./build/test_renderer # 渲染测试
|
||||
./build/test_layout # 布局+图片测试
|
||||
./build/test_http_async # HTTP异步测试
|
||||
./build/test_html_parse # HTML解析测试
|
||||
./build/test_bookmark # 书签测试
|
||||
```
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 键 | 功能 |
|
||||
|---|---|
|
||||
| j/k | 上下滚动 |
|
||||
| Ctrl+d/u | 翻页 |
|
||||
| gg/G | 顶部/底部 |
|
||||
| Tab/Shift+Tab | 切换链接/表单字段 |
|
||||
| Enter | 跟随链接/激活字段 |
|
||||
| i | 聚焦首个表单字段 |
|
||||
| h/l | 后退/前进 |
|
||||
| / | 搜索 |
|
||||
| n/N | 下一个/上一个匹配 |
|
||||
| r | 刷新 (跳过缓存) |
|
||||
| B | 添加书签 |
|
||||
| D | 删除书签 |
|
||||
| :o URL | 打开URL |
|
||||
| :bookmarks | 查看书签 |
|
||||
| :history | 查看历史 |
|
||||
| :q | 退出 |
|
||||
| ? | 帮助 |
|
||||
| Esc | 取消加载/退出编辑 |
|
||||
|
||||
**表单编辑模式** (INSERT):
|
||||
- 输入字符 - 编辑文本
|
||||
- Enter/Esc - 完成编辑
|
||||
|
||||
**下拉选择模式** (SELECT):
|
||||
- j/k, ↓/↑ - 导航选项
|
||||
- Enter - 选择选项
|
||||
- Esc - 取消选择
|
||||
|
||||
## 下一步功能优先级
|
||||
|
||||
1. **Cookie 持久化** - 保存和自动发送 Cookie (已有内存Cookie支持)
|
||||
2. **表单提交改进** - 文件上传、multipart/form-data
|
||||
3. **更多HTML5支持** - <table>表格渲染、<pre>代码块
|
||||
4. **性能优化** - DNS缓存、连接复用、HTTP/2
|
||||
|
||||
## 恢复对话时说
|
||||
|
||||
> "continue"
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## 测试指南
|
||||
|
||||
查看 `TESTING.md` 获取完整测试指南,或运行:
|
||||
|
||||
```bash
|
||||
./test_browser.sh
|
||||
```
|
||||
|
||||
## 浏览器特性总结
|
||||
|
||||
✓ **核心功能** - 异步HTTP加载、页面缓存、差分渲染
|
||||
✓ **导航** - 滚动、链接、历史记录
|
||||
✓ **搜索** - 全文搜索、高亮、导航
|
||||
✓ **表单** - 文本输入、复选框、下拉选择
|
||||
✓ **书签** - 持久化书签管理
|
||||
✓ **历史** - 浏览历史记录
|
||||
✓ **图片** - ASCII艺术渲染、智能缓存、异步加载
|
||||
✓ **性能** - LRU缓存、差分渲染、异步加载、多并发下载
|
||||
|
||||
## 技术亮点
|
||||
|
||||
- **完全异步**: 页面和图片都使用异步加载,UI永不阻塞
|
||||
- **渐进式渲染**: 图片下载完立即显示,无需等待全部完成
|
||||
- **多并发下载**: 最多3张图片同时下载,显著提升加载速度
|
||||
- **智能缓存**: 页面5分钟缓存、图片10分钟缓存,LRU策略
|
||||
- **差分渲染**: 只更新变化的屏幕区域,减少闪烁
|
||||
- **真彩色支持**: 24位True Color图片渲染
|
||||
|
||||
---
|
||||
更新时间: 2025-12-28
|
||||
411
README.md
411
README.md
|
|
@ -1,272 +1,201 @@
|
|||
TUT(1) - Terminal User Interface Browser
|
||||
========================================
|
||||
# TUT - Terminal UI Textual Browser
|
||||
|
||||
NAME
|
||||
----
|
||||
tut - vim-style terminal web browser
|
||||
A lightweight, high-performance terminal browser with a btop-style interface.
|
||||
|
||||
SYNOPSIS
|
||||
--------
|
||||
**tut** [*URL*]
|
||||

|
||||

|
||||

|
||||
|
||||
**tut** **-h** | **--help**
|
||||
## Features
|
||||
|
||||
DESCRIPTION
|
||||
-----------
|
||||
**tut** is a text-mode web browser designed for comfortable reading in the
|
||||
terminal. It extracts and displays the textual content of web pages with a
|
||||
clean, centered layout optimized for reading, while providing vim-style
|
||||
keyboard navigation.
|
||||
- **btop-style UI** - Modern four-panel layout with rounded borders
|
||||
- **Lightweight** - Binary size < 1MB, memory usage < 50MB
|
||||
- **Fast startup** - Launch in < 500ms
|
||||
- **Vim-style navigation** - j/k scrolling, / search, g/G jump
|
||||
- **Keyboard-driven** - Full keyboard navigation with function key shortcuts
|
||||
- **Themeable** - Multiple color themes (default, nord, gruvbox, solarized)
|
||||
- **Configurable** - TOML-based configuration
|
||||
|
||||
The browser does not execute JavaScript or display images. It is designed
|
||||
for reading static HTML content, documentation, and text-heavy websites.
|
||||
## Screenshot
|
||||
|
||||
OPTIONS
|
||||
-------
|
||||
*URL*
|
||||
Open the specified URL on startup. If omitted, displays the built-in
|
||||
help page.
|
||||
```
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│[◀] [▶] [⟳] ╭────────────────────────────────────────────────────────╮ [⚙] [?]│
|
||||
│ │https://example.com │ │
|
||||
│ ╰────────────────────────────────────────────────────────╯ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Example Domain │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│This domain is for use in illustrative examples in documents. │
|
||||
│ │
|
||||
│[1] More information... │
|
||||
│ │
|
||||
├────────────────────────────────────────┬─────────────────────────────────────┤
|
||||
│📑 Bookmarks │📊 Status │
|
||||
│ example.com │ ⬇ 1.2 KB 🕐 0.3s │
|
||||
├────────────────────────────────────────┴─────────────────────────────────────┤
|
||||
│[F1]Help [F2]Bookmarks [F3]History [F10]Quit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
**-h**, **--help**
|
||||
Display usage information and exit.
|
||||
## Installation
|
||||
|
||||
KEYBINDINGS
|
||||
-----------
|
||||
**tut** uses vim-style keybindings throughout.
|
||||
|
||||
### Navigation
|
||||
|
||||
**j**, **Down**
|
||||
Scroll down one line.
|
||||
|
||||
**k**, **Up**
|
||||
Scroll up one line.
|
||||
|
||||
**Ctrl-D**, **Space**
|
||||
Scroll down one page.
|
||||
|
||||
**Ctrl-U**, **b**
|
||||
Scroll up one page.
|
||||
|
||||
**gg**
|
||||
Jump to top of page.
|
||||
|
||||
**G**
|
||||
Jump to bottom of page.
|
||||
|
||||
**[***count***]G**
|
||||
Jump to line *count* (e.g., **50G** jumps to line 50).
|
||||
|
||||
**[***count***]j**, **[***count***]k**
|
||||
Scroll down/up *count* lines (e.g., **5j** scrolls down 5 lines).
|
||||
|
||||
### Link Navigation
|
||||
|
||||
**Tab**
|
||||
Move to next link.
|
||||
|
||||
**Shift-Tab**, **T**
|
||||
Move to previous link.
|
||||
|
||||
**Enter**
|
||||
Follow current link.
|
||||
|
||||
**h**, **Left**
|
||||
Go back in history.
|
||||
|
||||
**l**, **Right**
|
||||
Go forward in history.
|
||||
|
||||
### Search
|
||||
|
||||
**/**
|
||||
Start search. Enter search term and press **Enter**.
|
||||
|
||||
**n**
|
||||
Jump to next search match.
|
||||
|
||||
**N**
|
||||
Jump to previous search match.
|
||||
|
||||
### Marks
|
||||
|
||||
**m***[a-z]*
|
||||
Set mark at current position (e.g., **ma**, **mb**).
|
||||
|
||||
**'***[a-z]*
|
||||
Jump to mark (e.g., **'a**, **'b**).
|
||||
|
||||
### Mouse
|
||||
|
||||
**Left Click**
|
||||
Click on links to follow them directly.
|
||||
|
||||
**Scroll Wheel Up/Down**
|
||||
Scroll page up or down.
|
||||
|
||||
Works with most modern terminal emulators that support mouse events.
|
||||
|
||||
### Commands
|
||||
|
||||
Press **:** to enter command mode. Available commands:
|
||||
|
||||
**:q**, **:quit**
|
||||
Quit the browser.
|
||||
|
||||
**:o** *URL*, **:open** *URL*
|
||||
Open *URL*.
|
||||
|
||||
**:r**, **:refresh**
|
||||
Reload current page.
|
||||
|
||||
**:h**, **:help**
|
||||
Display help page.
|
||||
|
||||
**:***number*
|
||||
Jump to line *number*.
|
||||
|
||||
### Other
|
||||
|
||||
**r**
|
||||
Reload current page.
|
||||
|
||||
**q**
|
||||
Quit the browser.
|
||||
|
||||
**?**
|
||||
Display help page.
|
||||
|
||||
**ESC**
|
||||
Cancel command or search input.
|
||||
|
||||
LIMITATIONS
|
||||
-----------
|
||||
**tut** does not execute JavaScript. Modern single-page applications (SPAs)
|
||||
built with React, Vue, Angular, or similar frameworks will not work correctly,
|
||||
as they require JavaScript to render content.
|
||||
|
||||
To determine if a site will work with **tut**, use:
|
||||
|
||||
curl https://example.com | less
|
||||
|
||||
If you can see the actual content in the HTML source, the site will work.
|
||||
If you only see JavaScript code or empty div elements, it will not.
|
||||
|
||||
Additionally:
|
||||
- No image display
|
||||
- No CSS layout support
|
||||
- No AJAX or dynamic content loading
|
||||
|
||||
EXAMPLES
|
||||
--------
|
||||
View the built-in help:
|
||||
|
||||
tut
|
||||
|
||||
Browse Hacker News:
|
||||
|
||||
tut https://news.ycombinator.com
|
||||
|
||||
Read Wikipedia:
|
||||
|
||||
tut https://en.wikipedia.org/wiki/Unix_philosophy
|
||||
|
||||
Open a URL, search for "unix", and navigate:
|
||||
|
||||
tut https://example.com
|
||||
/unix<Enter>
|
||||
n
|
||||
|
||||
DEPENDENCIES
|
||||
------------
|
||||
- ncurses or ncursesw (for terminal UI)
|
||||
- libcurl (for HTTPS support)
|
||||
- CMake >= 3.15 (build time)
|
||||
- C++17 compiler (build time)
|
||||
|
||||
INSTALLATION
|
||||
------------
|
||||
### From Source
|
||||
### Prerequisites
|
||||
|
||||
**macOS (Homebrew):**
|
||||
|
||||
brew install cmake ncurses curl
|
||||
mkdir -p build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
sudo install -m 755 tut /usr/local/bin/
|
||||
```bash
|
||||
brew install cmake gumbo-parser openssl ftxui cpp-httplib toml11
|
||||
```
|
||||
|
||||
**Linux (Debian/Ubuntu):**
|
||||
```bash
|
||||
sudo apt install cmake libgumbo-dev libssl-dev
|
||||
```
|
||||
|
||||
sudo apt-get install cmake libncursesw5-dev libcurl4-openssl-dev
|
||||
mkdir -p build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
sudo install -m 755 tut /usr/local/bin/
|
||||
### Building from Source
|
||||
|
||||
**Linux (Fedora/RHEL):**
|
||||
```bash
|
||||
git clone https://github.com/m1ngsama/TUT.git
|
||||
cd TUT
|
||||
cmake -B build -DCMAKE_PREFIX_PATH=/opt/homebrew # macOS
|
||||
cmake -B build # Linux
|
||||
cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
sudo dnf install cmake gcc-c++ ncurses-devel libcurl-devel
|
||||
mkdir -p build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
sudo install -m 755 tut /usr/local/bin/
|
||||
### Running
|
||||
|
||||
### Using Makefile
|
||||
```bash
|
||||
./build/tut # Start with blank page
|
||||
./build/tut https://example.com # Open URL directly
|
||||
./build/tut --help # Show help
|
||||
```
|
||||
|
||||
make
|
||||
sudo make install
|
||||
## Keyboard Shortcuts
|
||||
|
||||
FILES
|
||||
-----
|
||||
No configuration files are used. The browser is stateless and does not
|
||||
store history, cookies, or cache.
|
||||
### Navigation
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `j` / `↓` | Scroll down |
|
||||
| `k` / `↑` | Scroll up |
|
||||
| `Space` | Page down |
|
||||
| `b` | Page up |
|
||||
| `g` | Go to top |
|
||||
| `G` | Go to bottom |
|
||||
| `Backspace` | Go back |
|
||||
|
||||
ENVIRONMENT
|
||||
-----------
|
||||
**tut** respects the following environment variables:
|
||||
### Links
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Tab` | Next link |
|
||||
| `Shift+Tab` | Previous link |
|
||||
| `Enter` | Follow link |
|
||||
| `1-9` | Jump to link by number |
|
||||
|
||||
**TERM**
|
||||
Terminal type. Must support basic cursor movement and colors.
|
||||
### Search
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `/` | Start search |
|
||||
| `n` | Next result |
|
||||
| `N` | Previous result |
|
||||
|
||||
**LINES**, **COLUMNS**
|
||||
Terminal size. Automatically detected via ncurses.
|
||||
### UI
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Ctrl+L` | Focus address bar |
|
||||
| `F1` / `?` | Help |
|
||||
| `F2` | Bookmarks |
|
||||
| `F3` | History |
|
||||
| `Ctrl+D` | Add bookmark |
|
||||
| `Ctrl+Q` / `F10` / `q` | Quit |
|
||||
|
||||
EXIT STATUS
|
||||
-----------
|
||||
**0**
|
||||
Success.
|
||||
## Configuration
|
||||
|
||||
**1**
|
||||
Error occurred (e.g., invalid URL, network error, ncurses initialization
|
||||
failure).
|
||||
Configuration files are stored in `~/.config/tut/`:
|
||||
|
||||
PHILOSOPHY
|
||||
----------
|
||||
**tut** follows the Unix philosophy:
|
||||
```
|
||||
~/.config/tut/
|
||||
├── config.toml # Main configuration
|
||||
└── themes/ # Custom themes
|
||||
└── mytheme.toml
|
||||
```
|
||||
|
||||
1. Do one thing well: display and navigate text content from the web.
|
||||
2. Work with other programs: output can be piped, URLs can come from stdin.
|
||||
3. Simple and minimal: no configuration files, no persistent state.
|
||||
4. Text-focused: everything is text, processed and displayed cleanly.
|
||||
### Example config.toml
|
||||
|
||||
The design emphasizes keyboard efficiency, clean output, and staying out
|
||||
of your way.
|
||||
```toml
|
||||
[general]
|
||||
theme = "default"
|
||||
homepage = "https://example.com"
|
||||
debug = false
|
||||
|
||||
SEE ALSO
|
||||
--------
|
||||
lynx(1), w3m(1), curl(1), vim(1)
|
||||
[browser]
|
||||
timeout = 30
|
||||
user_agent = "TUT/0.1.0"
|
||||
|
||||
BUGS
|
||||
----
|
||||
Report bugs at: https://github.com/m1ngsama/TUT/issues
|
||||
[ui]
|
||||
word_wrap = true
|
||||
show_images = true
|
||||
```
|
||||
|
||||
AUTHORS
|
||||
-------
|
||||
m1ngsama <contact@m1ng.space>
|
||||
## Project Structure
|
||||
|
||||
Inspired by lynx, w3m, and vim.
|
||||
```
|
||||
TUT/
|
||||
├── CMakeLists.txt # Build configuration
|
||||
├── README.md # This file
|
||||
├── LICENSE # MIT License
|
||||
├── cmake/ # CMake modules
|
||||
│ └── version.hpp.in
|
||||
├── src/ # Source code
|
||||
│ ├── main.cpp # Entry point
|
||||
│ ├── core/ # Browser engine, HTTP, URL parsing
|
||||
│ ├── ui/ # FTXUI components
|
||||
│ ├── renderer/ # HTML rendering
|
||||
│ └── utils/ # Logger, config, themes
|
||||
├── tests/ # Unit and integration tests
|
||||
│ ├── unit/
|
||||
│ └── integration/
|
||||
└── assets/ # Default configurations
|
||||
├── config.toml
|
||||
├── themes/
|
||||
└── keybindings/
|
||||
```
|
||||
|
||||
LICENSE
|
||||
-------
|
||||
MIT License. See LICENSE file for details.
|
||||
## Dependencies
|
||||
|
||||
| Library | Purpose | Version |
|
||||
|---------|---------|---------|
|
||||
| [FTXUI](https://github.com/ArthurSonzogni/ftxui) | Terminal UI framework | 5.0+ |
|
||||
| [cpp-httplib](https://github.com/yhirose/cpp-httplib) | HTTP client | 0.14+ |
|
||||
| [gumbo-parser](https://github.com/google/gumbo-parser) | HTML parsing | 0.10+ |
|
||||
| [toml11](https://github.com/ToruNiina/toml11) | TOML configuration | 3.8+ |
|
||||
| [OpenSSL](https://www.openssl.org/) | HTTPS support | 1.1+ |
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No JavaScript** - SPAs and dynamic content won't work
|
||||
- **No CSS layout** - Only basic text formatting
|
||||
- **No images** - ASCII art rendering planned for future
|
||||
- **Text-only** - Focused on readable content
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read the coding style guidelines:
|
||||
|
||||
- C++17 standard
|
||||
- Google C++ Style Guide
|
||||
- Use `.hpp` for headers, `.cpp` for implementation
|
||||
- All public APIs must have documentation comments
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Authors
|
||||
|
||||
- **m1ngsama** - [GitHub](https://github.com/m1ngsama)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Inspired by [btop](https://github.com/aristocratos/btop) for UI design
|
||||
- [FTXUI](https://github.com/ArthurSonzogni/ftxui) for the amazing TUI framework
|
||||
- [lynx](https://lynx.invisible-island.net/) and [w3m](http://w3m.sourceforge.net/) for inspiration
|
||||
|
|
|
|||
|
|
@ -1,390 +0,0 @@
|
|||
# TUT Browser - Real World Testing Report
|
||||
**Date**: 2025-12-28
|
||||
**Version**: 2.0.0 (with Phase 10 async image loading)
|
||||
**Tester**: Automated + Manual Evaluation
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
We tested TUT with various website categories to evaluate:
|
||||
- ✅ **Loading Speed**: How quickly content becomes readable
|
||||
- ✅ **Image Loading**: Async behavior and progressive rendering
|
||||
- ✅ **Readability**: Content clarity and layout quality
|
||||
- ✅ **Responsiveness**: UI interaction during loading
|
||||
- ✅ **Overall UX**: Real human experience
|
||||
|
||||
---
|
||||
|
||||
## Test Results by Category
|
||||
|
||||
### 1️⃣ Simple Static Sites
|
||||
|
||||
#### Example.com (https://example.com)
|
||||
- **Loading**: ⚡ Instant (< 1 second)
|
||||
- **Images**: No images
|
||||
- **Readability**: ⭐⭐⭐⭐⭐ Excellent
|
||||
- **Content**: Clean, centered text with proper spacing
|
||||
- **Experience**: Perfect for simple pages
|
||||
|
||||
**Notes**:
|
||||
- Title renders correctly
|
||||
- Links are highlighted and navigable
|
||||
- Smooth scrolling experience
|
||||
- No issues detected
|
||||
|
||||
---
|
||||
|
||||
#### Motherfucking Website (https://motherfuckingwebsite.com)
|
||||
- **Loading**: ⚡ Instant
|
||||
- **Images**: None
|
||||
- **Readability**: ⭐⭐⭐⭐⭐ Excellent
|
||||
- **Content**: Long-form text with good line width
|
||||
- **Experience**: Great for text-heavy content
|
||||
|
||||
**Notes**:
|
||||
- Excellent for reading long articles
|
||||
- Good contrast with warm color scheme
|
||||
- vim-style navigation feels natural
|
||||
- Perfect for distraction-free reading
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ News & Discussion Sites
|
||||
|
||||
#### Hacker News (https://news.ycombinator.com)
|
||||
- **Loading**: ⚡ Fast (~1-2 seconds)
|
||||
- **Images**: Minimal (logo only)
|
||||
- **Readability**: ⭐⭐⭐⭐⭐ Excellent
|
||||
- **Content**: Compact list layout works well
|
||||
- **Experience**: Highly usable
|
||||
|
||||
**Notes**:
|
||||
- News titles are clear and clickable
|
||||
- Point counts and metadata visible
|
||||
- Tab navigation between links works perfectly
|
||||
- Great for browsing headlines
|
||||
- No JavaScript needed - works flawlessly
|
||||
- **VERDICT**: One of the best use cases for TUT!
|
||||
|
||||
---
|
||||
|
||||
#### Lobsters (https://lobste.rs)
|
||||
- **Loading**: ⚡ Fast (~1-2 seconds)
|
||||
- **Images**: User avatars (async load)
|
||||
- **Readability**: ⭐⭐⭐⭐ Very Good
|
||||
- **Content**: Clean thread list
|
||||
- **Experience**: Good
|
||||
|
||||
**Notes**:
|
||||
- Links are well-organized
|
||||
- Tags and categories visible
|
||||
- Async image loading doesn't block content
|
||||
- Minor: Some layout elements from CSS missing
|
||||
- **VERDICT**: Very usable for tech news
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Documentation Sites
|
||||
|
||||
#### curl Manual Page (https://curl.se/docs/manpage.html)
|
||||
- **Loading**: Medium (~2-3 seconds)
|
||||
- **Images**: Few technical diagrams
|
||||
- **Readability**: ⭐⭐⭐⭐ Very Good
|
||||
- **Content**: Code blocks and technical text
|
||||
- **Experience**: Usable
|
||||
|
||||
**Notes**:
|
||||
- Long technical content renders well
|
||||
- Code blocks need better formatting (no `<pre>` support yet)
|
||||
- Search function (`/`) very useful for finding options
|
||||
- Good for quick reference
|
||||
- **IMPROVEMENT NEEDED**: Better `<pre>` and `<code>` rendering
|
||||
|
||||
---
|
||||
|
||||
#### Rust Book (https://doc.rust-lang.org/book/)
|
||||
- **Loading**: Medium (~2-3 seconds)
|
||||
- **Images**: Code diagrams (async)
|
||||
- **Readability**: ⭐⭐⭐⭐ Very Good
|
||||
- **Content**: Educational text with examples
|
||||
- **Experience**: Good for learning
|
||||
|
||||
**Notes**:
|
||||
- Chapter navigation works
|
||||
- Code examples readable but could be better formatted
|
||||
- Async image loading for diagrams is smooth
|
||||
- Marks (`ma`, `'a`) useful for bookmarking chapters
|
||||
- **VERDICT**: Decent for technical reading
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ Wikipedia
|
||||
|
||||
#### Wikipedia - Unix (https://en.wikipedia.org/wiki/Unix)
|
||||
- **Loading**: Slow (~4-5 seconds for content + images)
|
||||
- **Images**: **Many** (diagrams, screenshots, photos)
|
||||
- **Readability**: ⭐⭐⭐ Good (with some issues)
|
||||
- **Content**: Dense encyclopedic text
|
||||
- **Experience**: Acceptable but needs improvements
|
||||
|
||||
**Notes**:
|
||||
- **Main content loads fast** - can start reading immediately
|
||||
- **Images load progressively** - see them appear one by one
|
||||
- Infoboxes and tables have layout issues (no table support)
|
||||
- Reference links [1][2][3] visible
|
||||
- **ASYNC IMAGE LOADING WORKS WELL**:
|
||||
- Page is usable while images download
|
||||
- Progress indicator shows "Loading images 3/8"
|
||||
- Can scroll and navigate during image loading
|
||||
- **IMPROVEMENT NEEDED**: Table rendering for infoboxes
|
||||
|
||||
**Real UX Experience**:
|
||||
```
|
||||
0s: Page title appears, can start reading
|
||||
1s: Main text content loaded, fully readable
|
||||
2s: First 3 images appear (3 concurrent downloads)
|
||||
3s: Next batch of images loads
|
||||
4s: All images complete
|
||||
```
|
||||
**UI stayed responsive throughout!** ✅
|
||||
|
||||
---
|
||||
|
||||
#### Wikipedia - World Wide Web (https://en.wikipedia.org/wiki/World_Wide_Web)
|
||||
- **Loading**: Similar to Unix page (~4-5s total)
|
||||
- **Images**: Multiple historical diagrams and screenshots
|
||||
- **Readability**: ⭐⭐⭐ Good
|
||||
- **Content**: Technical history, well-structured
|
||||
- **Experience**: Good with progressive loading
|
||||
|
||||
**Notes**:
|
||||
- Can read introduction while images load in background
|
||||
- Timeline sections readable
|
||||
- ASCII art rendering of diagrams is interesting but low-fi
|
||||
- **Progressive rendering really shines here**
|
||||
- No UI freezing even with 10+ images
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ Tech Blogs
|
||||
|
||||
#### LWN.net (https://lwn.net)
|
||||
- **Loading**: Fast (~2 seconds)
|
||||
- **Images**: Few embedded images
|
||||
- **Readability**: ⭐⭐⭐⭐ Very Good
|
||||
- **Content**: Article headlines and summaries
|
||||
- **Experience**: Good for browsing
|
||||
|
||||
**Notes**:
|
||||
- Article summaries clear and navigable
|
||||
- Links to full articles work well
|
||||
- Subscription wall notice visible
|
||||
- Good for tech news consumption
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
| Site Type | Avg Load Time | Image Count | Readability | Usability |
|
||||
|-----------|--------------|-------------|-------------|-----------|
|
||||
| Simple Static | 0.5s | 0 | ⭐⭐⭐⭐⭐ | Excellent |
|
||||
| News Sites | 1-2s | 0-5 | ⭐⭐⭐⭐⭐ | Excellent |
|
||||
| Documentation | 2-3s | 3-10 | ⭐⭐⭐⭐ | Very Good |
|
||||
| Wikipedia | 4-5s | 8-15 | ⭐⭐⭐ | Good |
|
||||
| Tech Blogs | 2s | 2-8 | ⭐⭐⭐⭐ | Very Good |
|
||||
|
||||
---
|
||||
|
||||
## Async Image Loading - Real World Performance
|
||||
|
||||
### ✅ What Works Great
|
||||
|
||||
1. **Non-Blocking UI**
|
||||
- Can scroll, navigate, search while images download
|
||||
- Esc key cancels loading instantly
|
||||
- No frozen UI at any point
|
||||
|
||||
2. **Progressive Rendering**
|
||||
- Content appears immediately
|
||||
- Images pop in as they finish
|
||||
- Always know what's loading (progress indicator)
|
||||
|
||||
3. **Parallel Downloads**
|
||||
- 3 concurrent downloads significantly faster than sequential
|
||||
- Wikipedia with 10 images: ~4s vs estimated ~12s sequential
|
||||
- **3x speedup confirmed in real usage!**
|
||||
|
||||
4. **Cache Performance**
|
||||
- Revisiting pages is instant
|
||||
- Cached images don't re-download
|
||||
- Status shows "cached: 5" when applicable
|
||||
|
||||
### 📊 Before/After Comparison
|
||||
|
||||
**OLD (Synchronous Loading)**:
|
||||
```
|
||||
Load Wikipedia Unix page:
|
||||
0s: "Loading..." - UI FROZEN
|
||||
5s: "Downloading image 1/10..." - UI FROZEN
|
||||
10s: "Downloading image 2/10..." - UI FROZEN
|
||||
15s: "Downloading image 3/10..." - UI FROZEN
|
||||
...
|
||||
50s: Page finally usable
|
||||
```
|
||||
|
||||
**NEW (Async Loading)**:
|
||||
```
|
||||
Load Wikipedia Unix page:
|
||||
0s: Title and text appear - START READING
|
||||
1s: Can scroll, navigate, search
|
||||
2s: First 3 images appear (parallel download)
|
||||
3s: Next 3 images appear
|
||||
4s: All images complete
|
||||
UI responsive the ENTIRE time!
|
||||
```
|
||||
|
||||
**Human Experience**: **MASSIVELY BETTER** ✅
|
||||
|
||||
---
|
||||
|
||||
## Readability Assessment
|
||||
|
||||
### What Makes Content Readable in TUT?
|
||||
|
||||
✅ **Excellent for**:
|
||||
- News aggregators (HN, Lobsters, Reddit text)
|
||||
- Blog posts and articles
|
||||
- Documentation (with minor limitations)
|
||||
- Long-form reading
|
||||
- Technical reference material
|
||||
|
||||
⚠️ **Limitations**:
|
||||
- No `<table>` support (infoboxes, data tables render poorly)
|
||||
- No `<pre>` formatting (code blocks not monospaced)
|
||||
- No CSS layout (multi-column layouts flatten)
|
||||
- JavaScript-heavy sites don't work
|
||||
|
||||
### Content Clarity
|
||||
|
||||
- **Font**: Readable terminal font with good spacing
|
||||
- **Colors**: Warm color scheme easy on eyes
|
||||
- **Contrast**: Good foreground/background contrast
|
||||
- **Line Width**: Appropriate for reading (not too wide)
|
||||
- **Scrolling**: Smooth with vim keys (j/k)
|
||||
|
||||
### Navigation Experience
|
||||
|
||||
- **Tab**: Jump between links - works great
|
||||
- **Enter**: Follow links - instant
|
||||
- **h/l**: Back/forward - smooth
|
||||
- **/**: Search - very useful for finding content
|
||||
- **Esc**: Cancel loading - responsive
|
||||
|
||||
---
|
||||
|
||||
## Real Human Feelings 🧑💻
|
||||
|
||||
### What Users Will Love ❤️
|
||||
|
||||
1. **Speed**: "It's so fast! Content appears instantly."
|
||||
2. **Simplicity**: "No ads, no tracking, no distractions."
|
||||
3. **Keyboard Control**: "vim keys everywhere - feels natural."
|
||||
4. **Readability**: "Text-focused, perfect for reading articles."
|
||||
5. **Lightweight**: "Doesn't slow down my machine."
|
||||
|
||||
### What Users Will Notice 🤔
|
||||
|
||||
1. **Image Quality**: "ASCII art images are fun but low-resolution."
|
||||
2. **Missing Tables**: "Wikipedia infoboxes are messy."
|
||||
3. **Layout**: "Some sites look different without CSS."
|
||||
4. **No JavaScript**: "Modern web apps don't work."
|
||||
|
||||
### Best Use Cases ⭐
|
||||
|
||||
1. **News Browsing**: Hacker News, Lobsters, Reddit (text)
|
||||
2. **Documentation**: Reading technical docs and manuals
|
||||
3. **Wikipedia**: Quick research (despite table issues)
|
||||
4. **Blogs**: Reading articles and essays
|
||||
5. **Learning**: Following tutorials and guides
|
||||
|
||||
### Not Ideal For ❌
|
||||
|
||||
1. Shopping sites (complex layouts)
|
||||
2. Social media (JavaScript-heavy)
|
||||
3. Modern web apps (React/Vue sites)
|
||||
4. Video/audio content
|
||||
5. Complex data tables
|
||||
|
||||
---
|
||||
|
||||
## Recommendations for Future Improvements
|
||||
|
||||
### High Priority 🔥
|
||||
|
||||
1. **`<table>` Support** - Would fix Wikipedia infoboxes
|
||||
2. **`<pre>` Formatting** - Monospace code blocks
|
||||
3. **Better Link Indicators** - Show external vs internal links
|
||||
|
||||
### Medium Priority 💡
|
||||
|
||||
1. **Cookie Persistence** - Stay logged into sites
|
||||
2. **Form Submit Improvements** - Better form handling
|
||||
3. **Download Progress** - More detailed loading feedback
|
||||
|
||||
### Nice to Have ✨
|
||||
|
||||
1. **Custom Color Schemes** - Light/dark mode toggle
|
||||
2. **Font Size Control** - Adjustability
|
||||
3. **Bookmarklet** - Quick add current page
|
||||
|
||||
---
|
||||
|
||||
## Final Verdict 🎯
|
||||
|
||||
### Overall Rating: ⭐⭐⭐⭐ (4/5 stars)
|
||||
|
||||
**TUT is EXCELLENT for its intended purpose**: a fast, keyboard-driven, terminal-based browser for text-focused browsing.
|
||||
|
||||
### Phase 10 Async Image Loading: ✅ **SUCCESS**
|
||||
|
||||
The async image loading implementation is a **game changer**:
|
||||
- UI remains responsive at all times
|
||||
- Progressive rendering feels modern and smooth
|
||||
- 3x faster than synchronous loading
|
||||
- Real performance gains visible on image-heavy sites
|
||||
|
||||
### Real Human Feeling
|
||||
|
||||
> **"TUT feels like a breath of fresh air in the modern web."**
|
||||
>
|
||||
> It strips away all the bloat and gives you what matters: **content**.
|
||||
> The async image loading makes it feel fast and responsive, even
|
||||
> on complex pages. For reading news, docs, and articles, it's
|
||||
> genuinely enjoyable to use.
|
||||
>
|
||||
> Yes, it has limitations (tables, complex layouts), but for its
|
||||
> core use case - **focused, distraction-free reading** - it's
|
||||
> fantastic. The vim keybindings and instant response make it feel
|
||||
> like a native terminal tool, not a sluggish browser.
|
||||
>
|
||||
> **Would I use it daily?** Yes, for HN, docs, and Wikipedia lookups.
|
||||
> **Would I replace Chrome?** No, but that's not the point.
|
||||
> **Is it readable?** Absolutely. Better than many terminal browsers.
|
||||
|
||||
---
|
||||
|
||||
## Test Conclusion
|
||||
|
||||
✅ **Async image loading works flawlessly in real-world usage**
|
||||
✅ **Content is highly readable for text-focused sites**
|
||||
✅ **Performance is excellent across all site categories**
|
||||
✅ **User experience is smooth and responsive**
|
||||
⚠️ **Some limitations remain (tables, complex CSS) - acceptable trade-offs**
|
||||
|
||||
**TUT 2.0 with Phase 10 is production-ready for its target audience!** 🚀
|
||||
|
||||
---
|
||||
|
||||
**Testing Completed**: 2025-12-28
|
||||
**Next Phase**: Consider table rendering support for even better Wikipedia experience
|
||||
146
TESTING.md
146
TESTING.md
|
|
@ -1,146 +0,0 @@
|
|||
# TUT Browser Testing Guide
|
||||
|
||||
This document provides comprehensive testing instructions to ensure the browser works correctly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build the browser
|
||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build
|
||||
|
||||
# Run with a test site
|
||||
./build/tut http://example.com
|
||||
|
||||
# Or use the interactive test script
|
||||
./test_browser.sh
|
||||
```
|
||||
|
||||
## Feature Testing Checklist
|
||||
|
||||
### Basic Navigation
|
||||
- [ ] Browser loads and displays page correctly
|
||||
- [ ] Scroll with j/k works smoothly
|
||||
- [ ] Page up/down (Ctrl+d/u) works
|
||||
- [ ] Go to top (gg) and bottom (G) works
|
||||
- [ ] Back (h) and forward (l) navigation works
|
||||
|
||||
### Link Navigation
|
||||
- [ ] Tab cycles through links
|
||||
- [ ] Shift+Tab goes to previous link
|
||||
- [ ] Enter follows the active link
|
||||
- [ ] Links are highlighted when active
|
||||
- [ ] Link URLs shown in status bar
|
||||
|
||||
### Search
|
||||
- [ ] Press / to enter search mode
|
||||
- [ ] Type search term and press Enter
|
||||
- [ ] Matches are highlighted
|
||||
- [ ] n/N navigate between matches
|
||||
- [ ] Search count shown in status bar
|
||||
|
||||
### Form Interaction
|
||||
- [ ] Press 'i' to focus first form field
|
||||
- [ ] Tab/Shift+Tab navigate between fields
|
||||
- [ ] Enter on text input enters edit mode
|
||||
- [ ] Text can be typed and edited
|
||||
- [ ] Backspace removes characters
|
||||
- [ ] Enter or Esc exits edit mode
|
||||
- [ ] Checkbox toggles with Enter
|
||||
- [ ] SELECT dropdown shows options
|
||||
- [ ] j/k navigate dropdown options
|
||||
- [ ] Enter selects option in dropdown
|
||||
- [ ] Selected option displays correctly
|
||||
|
||||
### Bookmarks
|
||||
- [ ] Press B to bookmark current page
|
||||
- [ ] Press D to remove bookmark
|
||||
- [ ] Type :bookmarks to view all bookmarks
|
||||
- [ ] Bookmarks persist between sessions
|
||||
- [ ] Can click bookmarks to open pages
|
||||
|
||||
### History
|
||||
- [ ] Type :history to view browsing history
|
||||
- [ ] History shows URLs and titles
|
||||
- [ ] History entries are clickable
|
||||
- [ ] History persists between sessions
|
||||
- [ ] Recent pages appear at top
|
||||
|
||||
### Performance
|
||||
- [ ] Page loads are async with spinner
|
||||
- [ ] Esc cancels page loading
|
||||
- [ ] Page cache works (revisit loads instantly)
|
||||
- [ ] Image cache works (images load from cache)
|
||||
- [ ] Status shows "cached: N" for cached images
|
||||
- [ ] Scrolling is smooth
|
||||
- [ ] No noticeable lag in UI
|
||||
|
||||
### Commands
|
||||
- [ ] :o URL opens new URL
|
||||
- [ ] :q quits the browser
|
||||
- [ ] :bookmarks shows bookmarks
|
||||
- [ ] :history shows history
|
||||
- [ ] :help shows help page
|
||||
- [ ] ? also shows help page
|
||||
|
||||
### Edge Cases
|
||||
- [ ] Window resize updates layout correctly
|
||||
- [ ] Very long pages scroll correctly
|
||||
- [ ] Pages without links/forms work
|
||||
- [ ] Unicode text displays correctly
|
||||
- [ ] CJK characters display correctly
|
||||
- [ ] Images render as ASCII art (if stb_image available)
|
||||
- [ ] Error handling for failed page loads
|
||||
|
||||
## Test Websites
|
||||
|
||||
### Simple Test Sites
|
||||
1. **http://example.com** - Basic HTML test
|
||||
2. **http://info.cern.ch** - First website ever, very simple
|
||||
3. **http://motherfuckingwebsite.com** - Minimalist design
|
||||
4. **http://textfiles.com** - Text-only content
|
||||
|
||||
### Form Testing
|
||||
Create a local test file (test_form.html is provided):
|
||||
```bash
|
||||
python3 -m http.server 8000
|
||||
./build/tut http://localhost:8000/test_form.html
|
||||
```
|
||||
|
||||
Test the form features:
|
||||
- Text input editing
|
||||
- Checkbox toggling
|
||||
- Dropdown selection
|
||||
- Tab navigation
|
||||
|
||||
### Performance Testing
|
||||
1. Load a page
|
||||
2. Press 'r' to refresh (should use cache)
|
||||
3. Load the same page again (should be instant from cache)
|
||||
4. Check status bar shows "cached" messages
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- HTTPS support depends on libcurl configuration
|
||||
- Some complex JavaScript-heavy sites won't work (static HTML only)
|
||||
- File:// URLs may not work depending on curl configuration
|
||||
- Form submission is not yet implemented
|
||||
- Cookies are not yet supported
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting issues, please include:
|
||||
1. The URL you were trying to load
|
||||
2. The exact steps to reproduce
|
||||
3. Expected vs actual behavior
|
||||
4. Any error messages
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The browser is working correctly if:
|
||||
1. ✓ Can load and display simple HTML pages
|
||||
2. ✓ Navigation (scroll, links) works smoothly
|
||||
3. ✓ Form interaction is responsive and intuitive
|
||||
4. ✓ Bookmarks and history persist correctly
|
||||
5. ✓ Caching improves performance noticeably
|
||||
6. ✓ No crashes or hangs during normal use
|
||||
248
src/bookmark.cpp
248
src/bookmark.cpp
|
|
@ -1,248 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
#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
|
||||
1279
src/browser.cpp
1279
src/browser.cpp
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
||||
/**
|
||||
* Browser - TUT 终端浏览器
|
||||
*
|
||||
* 使用 Terminal + FrameBuffer + Renderer + LayoutEngine 架构
|
||||
* 支持 True Color, Unicode, 差分渲染
|
||||
*/
|
||||
class Browser {
|
||||
public:
|
||||
Browser();
|
||||
~Browser();
|
||||
|
||||
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;
|
||||
};
|
||||
23
src/core/types.hpp
Normal file
23
src/core/types.hpp
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* @file types.hpp
|
||||
* @brief Common types used across TUT modules
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-31
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 链接信息结构体
|
||||
*/
|
||||
struct LinkInfo {
|
||||
std::string url; ///< 链接 URL
|
||||
std::string text; ///< 链接文本
|
||||
int line{0}; ///< 所在行号
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
705
src/dom_tree.cpp
705
src/dom_tree.cpp
|
|
@ -1,705 +0,0 @@
|
|||
#include "dom_tree.h"
|
||||
#include <gumbo.h>
|
||||
#include <regex>
|
||||
#include <cctype>
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
|
||||
// ============================================================================
|
||||
// DomNode 辅助方法实现
|
||||
// ============================================================================
|
||||
|
||||
bool DomNode::is_block_element() const {
|
||||
if (node_type != NodeType::ELEMENT) return false;
|
||||
|
||||
switch (element_type) {
|
||||
case ElementType::HEADING1:
|
||||
case ElementType::HEADING2:
|
||||
case ElementType::HEADING3:
|
||||
case ElementType::HEADING4:
|
||||
case ElementType::HEADING5:
|
||||
case ElementType::HEADING6:
|
||||
case ElementType::PARAGRAPH:
|
||||
case ElementType::LIST_ITEM:
|
||||
case ElementType::ORDERED_LIST_ITEM:
|
||||
case ElementType::BLOCKQUOTE:
|
||||
case ElementType::CODE_BLOCK:
|
||||
case ElementType::HORIZONTAL_RULE:
|
||||
case ElementType::TABLE:
|
||||
case ElementType::SECTION_START:
|
||||
case ElementType::SECTION_END:
|
||||
case ElementType::NAV_START:
|
||||
case ElementType::NAV_END:
|
||||
case ElementType::HEADER_START:
|
||||
case ElementType::HEADER_END:
|
||||
case ElementType::ASIDE_START:
|
||||
case ElementType::ASIDE_END:
|
||||
case ElementType::FORM:
|
||||
return true;
|
||||
default:
|
||||
// 通过标签名判断
|
||||
return tag_name == "div" || tag_name == "section" ||
|
||||
tag_name == "article" || tag_name == "main" ||
|
||||
tag_name == "header" || tag_name == "footer" ||
|
||||
tag_name == "nav" || tag_name == "aside" ||
|
||||
tag_name == "ul" || tag_name == "ol" ||
|
||||
tag_name == "li" || tag_name == "dl" ||
|
||||
tag_name == "dt" || tag_name == "dd" ||
|
||||
tag_name == "pre" || tag_name == "hr" ||
|
||||
tag_name == "table" || tag_name == "tr" ||
|
||||
tag_name == "th" || tag_name == "td" ||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
bool DomNode::is_inline_element() const {
|
||||
if (node_type != NodeType::ELEMENT) return false;
|
||||
|
||||
switch (element_type) {
|
||||
case ElementType::LINK:
|
||||
case ElementType::TEXT:
|
||||
case ElementType::INPUT:
|
||||
case ElementType::TEXTAREA:
|
||||
case ElementType::SELECT:
|
||||
case ElementType::BUTTON:
|
||||
case ElementType::OPTION:
|
||||
return true;
|
||||
default:
|
||||
// 通过标签名判断常见的内联元素
|
||||
return tag_name == "a" || tag_name == "span" ||
|
||||
tag_name == "strong" || tag_name == "b" ||
|
||||
tag_name == "em" || tag_name == "i" ||
|
||||
tag_name == "code" || tag_name == "kbd" ||
|
||||
tag_name == "mark" || tag_name == "small" ||
|
||||
tag_name == "sub" || tag_name == "sup" ||
|
||||
tag_name == "u" || tag_name == "abbr" ||
|
||||
tag_name == "cite" || tag_name == "q" ||
|
||||
tag_name == "label";
|
||||
}
|
||||
}
|
||||
|
||||
bool DomNode::should_render() const {
|
||||
// 过滤不应该渲染的元素
|
||||
if (tag_name == "script" || tag_name == "style" ||
|
||||
tag_name == "noscript" || tag_name == "template" ||
|
||||
(tag_name == "input" && input_type == "hidden")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string DomNode::get_all_text() const {
|
||||
std::string result;
|
||||
|
||||
// 过滤不应该提取文本的元素
|
||||
if (!should_render()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (node_type == NodeType::TEXT) {
|
||||
result = text_content;
|
||||
} else {
|
||||
// Special handling for form elements to return their value/placeholder for representation
|
||||
if (element_type == ElementType::INPUT) {
|
||||
// For inputs, we might want to return nothing here as they are rendered specially,
|
||||
// or return their value. For simple text extraction, maybe empty is better.
|
||||
} else if (element_type == ElementType::TEXTAREA) {
|
||||
for (const auto& child : children) {
|
||||
result += child->get_all_text();
|
||||
}
|
||||
} else {
|
||||
for (const auto& child : children) {
|
||||
result += child->get_all_text();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DomTreeBuilder 实现
|
||||
// ============================================================================
|
||||
|
||||
// Add a member to track current form ID
|
||||
namespace {
|
||||
int g_current_form_id = -1;
|
||||
int g_next_form_id = 0;
|
||||
}
|
||||
|
||||
DomTreeBuilder::DomTreeBuilder() = default;
|
||||
DomTreeBuilder::~DomTreeBuilder() = default;
|
||||
|
||||
DocumentTree DomTreeBuilder::build(const std::string& html, const std::string& base_url) {
|
||||
// Reset form tracking
|
||||
g_current_form_id = -1;
|
||||
g_next_form_id = 0;
|
||||
|
||||
// 1. 使用gumbo解析HTML
|
||||
GumboOutput* output = gumbo_parse(html.c_str());
|
||||
|
||||
// 2. 转换为DomNode树
|
||||
DocumentTree tree;
|
||||
tree.url = base_url;
|
||||
tree.root = convert_node(output->root, tree.links, tree.form_fields, tree.images, base_url);
|
||||
|
||||
// 3. 提取标题
|
||||
if (tree.root) {
|
||||
tree.title = extract_title(tree.root.get());
|
||||
}
|
||||
|
||||
// 4. 清理gumbo资源
|
||||
gumbo_destroy_output(&kGumboDefaultOptions, output);
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
auto node = std::make_unique<DomNode>();
|
||||
|
||||
if (gumbo_node->type == GUMBO_NODE_ELEMENT) {
|
||||
node->node_type = NodeType::ELEMENT;
|
||||
GumboElement& element = gumbo_node->v.element;
|
||||
|
||||
// 设置标签名
|
||||
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;
|
||||
|
||||
// Special handling for FORM tag
|
||||
if (element.tag == GUMBO_TAG_FORM) {
|
||||
node->form_id = g_next_form_id++;
|
||||
g_current_form_id = node->form_id;
|
||||
|
||||
GumboAttribute* action_attr = gumbo_get_attribute(&element.attributes, "action");
|
||||
if (action_attr) node->action = resolve_url(action_attr->value, base_url);
|
||||
else node->action = base_url; // Default to current URL
|
||||
|
||||
GumboAttribute* method_attr = gumbo_get_attribute(&element.attributes, "method");
|
||||
if (method_attr) node->method = method_attr->value;
|
||||
else node->method = "GET";
|
||||
|
||||
// Transform to uppercase
|
||||
std::transform(node->method.begin(), node->method.end(), node->method.begin(), ::toupper);
|
||||
}
|
||||
|
||||
// Handle INPUT
|
||||
if (element.tag == GUMBO_TAG_INPUT) {
|
||||
GumboAttribute* type_attr = gumbo_get_attribute(&element.attributes, "type");
|
||||
node->input_type = type_attr ? type_attr->value : "text";
|
||||
|
||||
GumboAttribute* name_attr = gumbo_get_attribute(&element.attributes, "name");
|
||||
if (name_attr) node->name = name_attr->value;
|
||||
|
||||
GumboAttribute* value_attr = gumbo_get_attribute(&element.attributes, "value");
|
||||
if (value_attr) node->value = value_attr->value;
|
||||
|
||||
GumboAttribute* placeholder_attr = gumbo_get_attribute(&element.attributes, "placeholder");
|
||||
if (placeholder_attr) node->placeholder = placeholder_attr->value;
|
||||
|
||||
if (gumbo_get_attribute(&element.attributes, "checked")) {
|
||||
node->checked = true;
|
||||
}
|
||||
|
||||
// Register form field
|
||||
if (node->input_type != "hidden") {
|
||||
node->field_index = form_fields.size();
|
||||
form_fields.push_back(node.get());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle TEXTAREA
|
||||
if (element.tag == GUMBO_TAG_TEXTAREA) {
|
||||
node->input_type = "textarea";
|
||||
GumboAttribute* name_attr = gumbo_get_attribute(&element.attributes, "name");
|
||||
if (name_attr) node->name = name_attr->value;
|
||||
|
||||
GumboAttribute* placeholder_attr = gumbo_get_attribute(&element.attributes, "placeholder");
|
||||
if (placeholder_attr) node->placeholder = placeholder_attr->value;
|
||||
|
||||
// Register form field
|
||||
node->field_index = form_fields.size();
|
||||
form_fields.push_back(node.get());
|
||||
}
|
||||
|
||||
// Handle SELECT
|
||||
if (element.tag == GUMBO_TAG_SELECT) {
|
||||
node->input_type = "select";
|
||||
GumboAttribute* name_attr = gumbo_get_attribute(&element.attributes, "name");
|
||||
if (name_attr) node->name = name_attr->value;
|
||||
|
||||
// Register form field
|
||||
node->field_index = form_fields.size();
|
||||
form_fields.push_back(node.get());
|
||||
}
|
||||
|
||||
// Handle OPTION
|
||||
if (element.tag == GUMBO_TAG_OPTION) {
|
||||
node->input_type = "option";
|
||||
GumboAttribute* value_attr = gumbo_get_attribute(&element.attributes, "value");
|
||||
if (value_attr) node->value = value_attr->value;
|
||||
if (gumbo_get_attribute(&element.attributes, "selected")) {
|
||||
node->checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle BUTTON
|
||||
if (element.tag == GUMBO_TAG_BUTTON) {
|
||||
GumboAttribute* type_attr = gumbo_get_attribute(&element.attributes, "type");
|
||||
node->input_type = type_attr ? type_attr->value : "submit";
|
||||
|
||||
GumboAttribute* name_attr = gumbo_get_attribute(&element.attributes, "name");
|
||||
if (name_attr) node->name = name_attr->value;
|
||||
|
||||
GumboAttribute* value_attr = gumbo_get_attribute(&element.attributes, "value");
|
||||
if (value_attr) node->value = value_attr->value;
|
||||
|
||||
// Register form field
|
||||
node->field_index = form_fields.size();
|
||||
form_fields.push_back(node.get());
|
||||
}
|
||||
|
||||
// Handle IMG
|
||||
if (element.tag == GUMBO_TAG_IMG) {
|
||||
GumboAttribute* src_attr = gumbo_get_attribute(&element.attributes, "src");
|
||||
if (src_attr && src_attr->value) {
|
||||
node->img_src = resolve_url(src_attr->value, base_url);
|
||||
}
|
||||
|
||||
GumboAttribute* alt_attr = gumbo_get_attribute(&element.attributes, "alt");
|
||||
if (alt_attr) node->alt_text = alt_attr->value;
|
||||
|
||||
GumboAttribute* width_attr = gumbo_get_attribute(&element.attributes, "width");
|
||||
if (width_attr && width_attr->value) {
|
||||
try { node->img_width = std::stoi(width_attr->value); } catch (...) {}
|
||||
}
|
||||
|
||||
GumboAttribute* height_attr = gumbo_get_attribute(&element.attributes, "height");
|
||||
if (height_attr && height_attr->value) {
|
||||
try { node->img_height = std::stoi(height_attr->value); } catch (...) {}
|
||||
}
|
||||
|
||||
// 添加到图片列表(用于后续下载)
|
||||
if (!node->img_src.empty()) {
|
||||
images.push_back(node.get());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 处理<a>标签
|
||||
if (element.tag == GUMBO_TAG_A) {
|
||||
GumboAttribute* href_attr = gumbo_get_attribute(&element.attributes, "href");
|
||||
if (href_attr && href_attr->value) {
|
||||
std::string href = href_attr->value;
|
||||
// 过滤锚点链接和javascript链接
|
||||
if (!href.empty() && href[0] != '#' &&
|
||||
href.find("javascript:") != 0 &&
|
||||
href.find("mailto:") != 0) {
|
||||
|
||||
node->href = resolve_url(href, base_url);
|
||||
|
||||
// 注册到全局链接列表
|
||||
Link link;
|
||||
link.text = extract_text_from_gumbo(gumbo_node);
|
||||
link.url = node->href;
|
||||
link.position = links.size();
|
||||
|
||||
links.push_back(link);
|
||||
node->link_index = links.size() - 1;
|
||||
node->element_type = ElementType::LINK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理表格单元格属性
|
||||
if (element.tag == GUMBO_TAG_TH) {
|
||||
node->is_table_header = true;
|
||||
}
|
||||
|
||||
if (element.tag == GUMBO_TAG_TD || element.tag == GUMBO_TAG_TH) {
|
||||
GumboAttribute* colspan_attr = gumbo_get_attribute(&element.attributes, "colspan");
|
||||
if (colspan_attr && colspan_attr->value) {
|
||||
node->colspan = std::stoi(colspan_attr->value);
|
||||
}
|
||||
|
||||
GumboAttribute* rowspan_attr = gumbo_get_attribute(&element.attributes, "rowspan");
|
||||
if (rowspan_attr && rowspan_attr->value) {
|
||||
node->rowspan = std::stoi(rowspan_attr->value);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
GumboVector* children = &element.children;
|
||||
for (unsigned int i = 0; i < children->length; ++i) {
|
||||
auto child = convert_node(
|
||||
static_cast<GumboNode*>(children->data[i]),
|
||||
links,
|
||||
form_fields,
|
||||
images,
|
||||
base_url
|
||||
);
|
||||
if (child) {
|
||||
child->parent = node.get();
|
||||
node->children.push_back(std::move(child));
|
||||
|
||||
// For TEXTAREA, content is value
|
||||
if (element.tag == GUMBO_TAG_TEXTAREA && child->node_type == NodeType::TEXT) {
|
||||
node->value += child->text_content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For SELECT, collect all OPTION children
|
||||
if (element.tag == GUMBO_TAG_SELECT) {
|
||||
for (const auto& child : node->children) {
|
||||
if (child->element_type == ElementType::OPTION) {
|
||||
std::string option_value = child->value.empty() ? child->get_all_text() : child->value;
|
||||
std::string option_text = child->get_all_text();
|
||||
node->options.push_back({option_value, option_text});
|
||||
|
||||
// Set selected option if marked
|
||||
if (child->checked) {
|
||||
node->selected_option = node->options.size() - 1;
|
||||
node->value = option_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set default value to first option if no option is selected
|
||||
if (!node->options.empty() && node->value.empty()) {
|
||||
node->value = node->options[0].first;
|
||||
node->selected_option = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form ID if we are exiting a form
|
||||
if (element.tag == GUMBO_TAG_FORM) {
|
||||
g_current_form_id = -1; // Assuming no nested forms
|
||||
}
|
||||
}
|
||||
else if (gumbo_node->type == GUMBO_NODE_TEXT) {
|
||||
node->node_type = NodeType::TEXT;
|
||||
std::string text = gumbo_node->v.text.text;
|
||||
|
||||
// 解码HTML实体
|
||||
node->text_content = decode_html_entities(text);
|
||||
node->form_id = g_current_form_id;
|
||||
}
|
||||
else if (gumbo_node->type == GUMBO_NODE_DOCUMENT) {
|
||||
node->node_type = NodeType::DOCUMENT;
|
||||
node->tag_name = "document";
|
||||
|
||||
// 处理文档节点的子节点
|
||||
GumboDocument& doc = gumbo_node->v.document;
|
||||
for (unsigned int i = 0; i < doc.children.length; ++i) {
|
||||
auto child = convert_node(
|
||||
static_cast<GumboNode*>(doc.children.data[i]),
|
||||
links,
|
||||
form_fields,
|
||||
images,
|
||||
base_url
|
||||
);
|
||||
if (child) {
|
||||
child->parent = node.get();
|
||||
node->children.push_back(std::move(child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
std::string DomTreeBuilder::extract_title(DomNode* root) {
|
||||
if (!root) return "";
|
||||
|
||||
// 递归查找<title>标签
|
||||
std::function<std::string(DomNode*)> find_title = [&](DomNode* node) -> std::string {
|
||||
if (!node) return "";
|
||||
|
||||
if (node->tag_name == "title") {
|
||||
return node->get_all_text();
|
||||
}
|
||||
|
||||
for (auto& child : node->children) {
|
||||
std::string title = find_title(child.get());
|
||||
if (!title.empty()) return title;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
std::string title = find_title(root);
|
||||
|
||||
// 如果没有<title>,尝试找第一个<h1>
|
||||
if (title.empty()) {
|
||||
std::function<std::string(DomNode*)> find_h1 = [&](DomNode* node) -> std::string {
|
||||
if (!node) return "";
|
||||
|
||||
if (node->tag_name == "h1") {
|
||||
return node->get_all_text();
|
||||
}
|
||||
|
||||
for (auto& child : node->children) {
|
||||
std::string h1 = find_h1(child.get());
|
||||
if (!h1.empty()) return h1;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
title = find_h1(root);
|
||||
}
|
||||
|
||||
// 清理标题中的多余空白
|
||||
title = std::regex_replace(title, std::regex(R"(\s+)"), " ");
|
||||
|
||||
// 去除首尾空白
|
||||
size_t start = title.find_first_not_of(" \t\n\r");
|
||||
if (start == std::string::npos) return "";
|
||||
|
||||
size_t end = title.find_last_not_of(" \t\n\r");
|
||||
return title.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
std::string DomTreeBuilder::extract_text_from_gumbo(GumboNode* node) {
|
||||
if (!node) return "";
|
||||
|
||||
std::string text;
|
||||
|
||||
if (node->type == GUMBO_NODE_TEXT) {
|
||||
text = node->v.text.text;
|
||||
} else if (node->type == GUMBO_NODE_ELEMENT) {
|
||||
GumboVector* children = &node->v.element.children;
|
||||
for (unsigned int i = 0; i < children->length; ++i) {
|
||||
text += extract_text_from_gumbo(static_cast<GumboNode*>(children->data[i]));
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
ElementType DomTreeBuilder::map_gumbo_tag_to_element_type(int gumbo_tag) {
|
||||
switch (gumbo_tag) {
|
||||
case GUMBO_TAG_H1: return ElementType::HEADING1;
|
||||
case GUMBO_TAG_H2: return ElementType::HEADING2;
|
||||
case GUMBO_TAG_H3: return ElementType::HEADING3;
|
||||
case GUMBO_TAG_H4: return ElementType::HEADING4;
|
||||
case GUMBO_TAG_H5: return ElementType::HEADING5;
|
||||
case GUMBO_TAG_H6: return ElementType::HEADING6;
|
||||
case GUMBO_TAG_P: return ElementType::PARAGRAPH;
|
||||
case GUMBO_TAG_A: return ElementType::LINK;
|
||||
case GUMBO_TAG_LI: return ElementType::LIST_ITEM;
|
||||
case GUMBO_TAG_BLOCKQUOTE: return ElementType::BLOCKQUOTE;
|
||||
case GUMBO_TAG_PRE: return ElementType::CODE_BLOCK;
|
||||
case GUMBO_TAG_HR: return ElementType::HORIZONTAL_RULE;
|
||||
case GUMBO_TAG_BR: return ElementType::LINE_BREAK;
|
||||
case GUMBO_TAG_TABLE: return ElementType::TABLE;
|
||||
case GUMBO_TAG_IMG: return ElementType::IMAGE;
|
||||
case GUMBO_TAG_FORM: return ElementType::FORM;
|
||||
case GUMBO_TAG_INPUT: return ElementType::INPUT;
|
||||
case GUMBO_TAG_TEXTAREA: return ElementType::TEXTAREA;
|
||||
case GUMBO_TAG_SELECT: return ElementType::SELECT;
|
||||
case GUMBO_TAG_OPTION: return ElementType::OPTION;
|
||||
case GUMBO_TAG_BUTTON: return ElementType::BUTTON;
|
||||
default: return ElementType::TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
std::string DomTreeBuilder::resolve_url(const std::string& url, const std::string& base_url) {
|
||||
if (url.empty()) return "";
|
||||
|
||||
// 绝对URL(http://或https://)
|
||||
if (url.find("http://") == 0 || url.find("https://") == 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 协议相对URL(//example.com)
|
||||
if (url.size() >= 2 && url[0] == '/' && url[1] == '/') {
|
||||
// 从base_url提取协议
|
||||
size_t proto_end = base_url.find("://");
|
||||
if (proto_end != std::string::npos) {
|
||||
return base_url.substr(0, proto_end) + ":" + url;
|
||||
}
|
||||
return "https:" + url;
|
||||
}
|
||||
|
||||
if (base_url.empty()) return url;
|
||||
|
||||
// 绝对路径(/path)
|
||||
if (url[0] == '/') {
|
||||
// 提取base_url的scheme和host
|
||||
size_t proto_end = base_url.find("://");
|
||||
if (proto_end == std::string::npos) return url;
|
||||
|
||||
size_t host_start = proto_end + 3;
|
||||
size_t path_start = base_url.find('/', host_start);
|
||||
|
||||
std::string base_origin;
|
||||
if (path_start != std::string::npos) {
|
||||
base_origin = base_url.substr(0, path_start);
|
||||
} else {
|
||||
base_origin = base_url;
|
||||
}
|
||||
|
||||
return base_origin + url;
|
||||
}
|
||||
|
||||
// 相对路径(relative/path)
|
||||
// 找到base_url的路径部分
|
||||
size_t proto_end = base_url.find("://");
|
||||
if (proto_end == std::string::npos) return url;
|
||||
|
||||
size_t host_start = proto_end + 3;
|
||||
size_t path_start = base_url.find('/', host_start);
|
||||
|
||||
std::string base_path;
|
||||
if (path_start != std::string::npos) {
|
||||
// 找到最后一个/
|
||||
size_t last_slash = base_url.rfind('/');
|
||||
if (last_slash != std::string::npos) {
|
||||
base_path = base_url.substr(0, last_slash + 1);
|
||||
} else {
|
||||
base_path = base_url + "/";
|
||||
}
|
||||
} else {
|
||||
base_path = base_url + "/";
|
||||
}
|
||||
|
||||
return base_path + url;
|
||||
}
|
||||
|
||||
const std::map<std::string, std::string>& DomTreeBuilder::get_entity_map() {
|
||||
static std::map<std::string, std::string> entity_map = {
|
||||
{" ", " "}, {"<", "<"}, {">", ">"},
|
||||
{"&", "&"}, {""", "\""}, {"'", "'"},
|
||||
{"©", "©"}, {"®", "®"}, {"™", "™"},
|
||||
{"€", "€"}, {"£", "£"}, {"¥", "¥"},
|
||||
{"¢", "¢"}, {"§", "§"}, {"¶", "¶"},
|
||||
{"†", "†"}, {"‡", "‡"}, {"•", "•"},
|
||||
{"…", "…"}, {"′", "′"}, {"″", "″"},
|
||||
{"‹", "‹"}, {"›", "›"}, {"«", "«"},
|
||||
{"»", "»"}, {"‘", "'"}, {"’", "'"},
|
||||
{"“", "\u201C"}, {"”", "\u201D"}, {"—", "—"},
|
||||
{"–", "–"}, {"¡", "¡"}, {"¿", "¿"},
|
||||
{"×", "×"}, {"÷", "÷"}, {"±", "±"},
|
||||
{"°", "°"}, {"µ", "µ"}, {"·", "·"},
|
||||
{"¼", "¼"}, {"½", "½"}, {"¾", "¾"},
|
||||
{"¹", "¹"}, {"²", "²"}, {"³", "³"},
|
||||
{"α", "α"}, {"β", "β"}, {"γ", "γ"},
|
||||
{"δ", "δ"}, {"ε", "ε"}, {"θ", "θ"},
|
||||
{"λ", "λ"}, {"μ", "μ"}, {"π", "π"},
|
||||
{"σ", "σ"}, {"τ", "τ"}, {"φ", "φ"},
|
||||
{"ω", "ω"}
|
||||
};
|
||||
return entity_map;
|
||||
}
|
||||
|
||||
std::string DomTreeBuilder::decode_html_entities(const std::string& text) {
|
||||
std::string result = text;
|
||||
const auto& entity_map = get_entity_map();
|
||||
|
||||
// 替换命名实体
|
||||
for (const auto& [entity, replacement] : entity_map) {
|
||||
size_t pos = 0;
|
||||
while ((pos = result.find(entity, pos)) != std::string::npos) {
|
||||
result.replace(pos, entity.length(), replacement);
|
||||
pos += replacement.length();
|
||||
}
|
||||
}
|
||||
|
||||
// 替换数字实体 { 或 «
|
||||
std::regex numeric_entity(R"(&#(\d+);)");
|
||||
std::regex hex_entity(R"(&#x([0-9A-Fa-f]+);)");
|
||||
|
||||
// 处理十进制数字实体
|
||||
std::string temp;
|
||||
size_t last_pos = 0;
|
||||
std::smatch match;
|
||||
std::string::const_iterator search_start(result.cbegin());
|
||||
|
||||
while (std::regex_search(search_start, result.cend(), match, numeric_entity)) {
|
||||
size_t match_pos = match.position() + std::distance(result.cbegin(), search_start);
|
||||
temp += result.substr(last_pos, match_pos - last_pos);
|
||||
|
||||
int code = std::stoi(match[1].str());
|
||||
if (code > 0 && code < 0x110000) {
|
||||
// 简单的UTF-8编码(仅支持基本多文种平面)
|
||||
if (code < 0x80) {
|
||||
temp += static_cast<char>(code);
|
||||
} else if (code < 0x800) {
|
||||
temp += static_cast<char>(0xC0 | (code >> 6));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
} else if (code < 0x10000) {
|
||||
temp += static_cast<char>(0xE0 | (code >> 12));
|
||||
temp += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
} else {
|
||||
temp += static_cast<char>(0xF0 | (code >> 18));
|
||||
temp += static_cast<char>(0x80 | ((code >> 12) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
last_pos = match_pos + match[0].length();
|
||||
search_start = result.cbegin() + last_pos;
|
||||
}
|
||||
temp += result.substr(last_pos);
|
||||
result = temp;
|
||||
|
||||
// 处理十六进制数字实体
|
||||
temp.clear();
|
||||
last_pos = 0;
|
||||
search_start = result.cbegin();
|
||||
|
||||
while (std::regex_search(search_start, result.cend(), match, hex_entity)) {
|
||||
size_t match_pos = match.position() + std::distance(result.cbegin(), search_start);
|
||||
temp += result.substr(last_pos, match_pos - last_pos);
|
||||
|
||||
int code = std::stoi(match[1].str(), nullptr, 16);
|
||||
if (code > 0 && code < 0x110000) {
|
||||
if (code < 0x80) {
|
||||
temp += static_cast<char>(code);
|
||||
} else if (code < 0x800) {
|
||||
temp += static_cast<char>(0xC0 | (code >> 6));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
} else if (code < 0x10000) {
|
||||
temp += static_cast<char>(0xE0 | (code >> 12));
|
||||
temp += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
} else {
|
||||
temp += static_cast<char>(0xF0 | (code >> 18));
|
||||
temp += static_cast<char>(0x80 | ((code >> 12) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
last_pos = match_pos + match[0].length();
|
||||
search_start = result.cbegin() + last_pos;
|
||||
}
|
||||
temp += result.substr(last_pos);
|
||||
|
||||
return temp;
|
||||
}
|
||||
118
src/dom_tree.h
118
src/dom_tree.h
|
|
@ -1,118 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "html_parser.h"
|
||||
#include "render/image.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <map>
|
||||
|
||||
// Forward declaration for gumbo
|
||||
struct GumboInternalNode;
|
||||
struct GumboInternalOutput;
|
||||
typedef struct GumboInternalNode GumboNode;
|
||||
typedef struct GumboInternalOutput GumboOutput;
|
||||
|
||||
// DOM节点类型
|
||||
enum class NodeType {
|
||||
ELEMENT, // 元素节点(h1, p, div等)
|
||||
TEXT, // 文本节点
|
||||
DOCUMENT // 文档根节点
|
||||
};
|
||||
|
||||
// DOM节点结构
|
||||
struct DomNode {
|
||||
NodeType node_type;
|
||||
ElementType element_type; // 复用现有的ElementType
|
||||
std::string tag_name; // "div", "p", "h1"等
|
||||
std::string text_content; // TEXT节点的文本内容
|
||||
|
||||
// 树结构
|
||||
std::vector<std::unique_ptr<DomNode>> children;
|
||||
DomNode* parent = nullptr; // 非拥有指针
|
||||
|
||||
// 链接属性
|
||||
std::string href;
|
||||
int link_index = -1; // -1表示非链接
|
||||
int field_index = -1; // -1表示非表单字段
|
||||
|
||||
// 图片属性
|
||||
std::string img_src; // 图片URL
|
||||
std::string alt_text; // 图片alt文本
|
||||
int img_width = -1; // 图片宽度 (-1表示未指定)
|
||||
int img_height = -1; // 图片高度 (-1表示未指定)
|
||||
tut::ImageData image_data; // 解码后的图片数据
|
||||
|
||||
// 表格属性
|
||||
bool is_table_header = false;
|
||||
int colspan = 1;
|
||||
int rowspan = 1;
|
||||
|
||||
// 表单属性
|
||||
std::string action;
|
||||
std::string method;
|
||||
std::string name;
|
||||
std::string value;
|
||||
std::string input_type; // text, password, checkbox, radio, submit, hidden
|
||||
std::string placeholder;
|
||||
bool checked = false;
|
||||
int form_id = -1;
|
||||
|
||||
// SELECT元素的选项
|
||||
std::vector<std::pair<std::string, std::string>> options; // (value, text) pairs
|
||||
int selected_option = 0; // 当前选中的选项索引
|
||||
|
||||
// 辅助方法
|
||||
bool is_block_element() const;
|
||||
bool is_inline_element() const;
|
||||
bool should_render() const; // 是否应该渲染(过滤script、style等)
|
||||
std::string get_all_text() const; // 递归获取所有文本内容
|
||||
};
|
||||
|
||||
// 文档树结构
|
||||
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;
|
||||
};
|
||||
|
||||
// DOM树构建器
|
||||
class DomTreeBuilder {
|
||||
public:
|
||||
DomTreeBuilder();
|
||||
~DomTreeBuilder();
|
||||
|
||||
// 从HTML构建DOM树
|
||||
DocumentTree build(const std::string& html, const std::string& base_url);
|
||||
|
||||
private:
|
||||
// 将GumboNode转换为DomNode
|
||||
std::unique_ptr<DomNode> convert_node(
|
||||
GumboNode* gumbo_node,
|
||||
std::vector<Link>& links,
|
||||
std::vector<DomNode*>& form_fields,
|
||||
std::vector<DomNode*>& images,
|
||||
const std::string& base_url
|
||||
);
|
||||
|
||||
// 提取文档标题
|
||||
std::string extract_title(DomNode* root);
|
||||
|
||||
// 从GumboNode提取所有文本
|
||||
std::string extract_text_from_gumbo(GumboNode* node);
|
||||
|
||||
// 将GumboTag映射为ElementType
|
||||
ElementType map_gumbo_tag_to_element_type(int gumbo_tag);
|
||||
|
||||
// URL解析
|
||||
std::string resolve_url(const std::string& url, const std::string& base_url);
|
||||
|
||||
// HTML实体解码
|
||||
std::string decode_html_entities(const std::string& text);
|
||||
|
||||
// HTML实体映射表
|
||||
static const std::map<std::string, std::string>& get_entity_map();
|
||||
};
|
||||
217
src/history.cpp
217
src/history.cpp
|
|
@ -1,217 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
#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
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
#include "html_parser.h"
|
||||
#include "dom_tree.h"
|
||||
#include <stdexcept>
|
||||
|
||||
// ============================================================================
|
||||
// HtmlParser::Impl 实现
|
||||
// ============================================================================
|
||||
|
||||
class HtmlParser::Impl {
|
||||
public:
|
||||
bool keep_code_blocks = true;
|
||||
bool keep_lists = true;
|
||||
|
||||
DomTreeBuilder tree_builder;
|
||||
|
||||
DocumentTree parse_tree(const std::string& html, const std::string& base_url) {
|
||||
return tree_builder.build(html, base_url);
|
||||
}
|
||||
|
||||
// 将DocumentTree转换为ParsedDocument(向后兼容)
|
||||
ParsedDocument convert_to_parsed_document(const DocumentTree& tree) {
|
||||
ParsedDocument doc;
|
||||
doc.title = tree.title;
|
||||
doc.url = tree.url;
|
||||
doc.links = tree.links;
|
||||
|
||||
// 递归遍历DOM树,收集ContentElement
|
||||
if (tree.root) {
|
||||
collect_content_elements(tree.root.get(), doc.elements);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private:
|
||||
void collect_content_elements(DomNode* node, std::vector<ContentElement>& elements) {
|
||||
if (!node || !node->should_render()) return;
|
||||
|
||||
if (node->node_type == NodeType::ELEMENT) {
|
||||
ContentElement elem;
|
||||
elem.type = node->element_type;
|
||||
elem.url = node->href;
|
||||
elem.level = 0; // TODO: 根据需要计算层级
|
||||
elem.list_number = 0;
|
||||
elem.nesting_level = 0;
|
||||
|
||||
// 提取文本内容
|
||||
elem.text = node->get_all_text();
|
||||
|
||||
// 收集内联链接
|
||||
collect_inline_links(node, elem.inline_links);
|
||||
|
||||
// 只添加有内容的元素
|
||||
if (!elem.text.empty() || node->element_type == ElementType::HORIZONTAL_RULE) {
|
||||
elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
for (const auto& child : node->children) {
|
||||
collect_content_elements(child.get(), elements);
|
||||
}
|
||||
}
|
||||
|
||||
void collect_inline_links(DomNode* node, std::vector<InlineLink>& links) {
|
||||
if (!node) return;
|
||||
|
||||
if (node->element_type == ElementType::LINK && node->link_index >= 0) {
|
||||
InlineLink link;
|
||||
link.text = node->get_all_text();
|
||||
link.url = node->href;
|
||||
link.link_index = node->link_index;
|
||||
link.start_pos = 0; // 简化:不计算精确位置
|
||||
link.end_pos = link.text.length();
|
||||
links.push_back(link);
|
||||
}
|
||||
|
||||
for (const auto& child : node->children) {
|
||||
collect_inline_links(child.get(), links);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HtmlParser 公共接口实现
|
||||
// ============================================================================
|
||||
|
||||
HtmlParser::HtmlParser() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
||||
HtmlParser::~HtmlParser() = default;
|
||||
|
||||
DocumentTree HtmlParser::parse_tree(const std::string& html, const std::string& base_url) {
|
||||
return pImpl->parse_tree(html, base_url);
|
||||
}
|
||||
|
||||
ParsedDocument HtmlParser::parse(const std::string& html, const std::string& base_url) {
|
||||
// 使用新的DOM树解析,然后转换为旧格式
|
||||
DocumentTree tree = pImpl->parse_tree(html, base_url);
|
||||
return pImpl->convert_to_parsed_document(tree);
|
||||
}
|
||||
|
||||
void HtmlParser::set_keep_code_blocks(bool keep) {
|
||||
pImpl->keep_code_blocks = keep;
|
||||
}
|
||||
|
||||
void HtmlParser::set_keep_lists(bool keep) {
|
||||
pImpl->keep_lists = keep;
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
// Forward declaration
|
||||
struct DocumentTree;
|
||||
|
||||
enum class ElementType {
|
||||
TEXT,
|
||||
HEADING1,
|
||||
HEADING2,
|
||||
HEADING3,
|
||||
HEADING4,
|
||||
HEADING5,
|
||||
HEADING6,
|
||||
PARAGRAPH,
|
||||
LINK,
|
||||
LIST_ITEM,
|
||||
ORDERED_LIST_ITEM,
|
||||
BLOCKQUOTE,
|
||||
CODE_BLOCK,
|
||||
HORIZONTAL_RULE,
|
||||
LINE_BREAK,
|
||||
TABLE,
|
||||
IMAGE,
|
||||
FORM,
|
||||
INPUT,
|
||||
TEXTAREA,
|
||||
SELECT,
|
||||
OPTION,
|
||||
BUTTON,
|
||||
SECTION_START,
|
||||
SECTION_END,
|
||||
NAV_START,
|
||||
NAV_END,
|
||||
HEADER_START,
|
||||
HEADER_END,
|
||||
ASIDE_START,
|
||||
ASIDE_END
|
||||
};
|
||||
|
||||
struct Link {
|
||||
std::string text;
|
||||
std::string url;
|
||||
int position;
|
||||
};
|
||||
|
||||
struct InlineLink {
|
||||
std::string text;
|
||||
std::string url;
|
||||
size_t start_pos; // Position in the text where link starts
|
||||
size_t end_pos; // Position in the text where link ends
|
||||
int link_index; // Index in the document's links array
|
||||
int field_index = -1; // Index in the document's form_fields array
|
||||
};
|
||||
|
||||
struct TableCell {
|
||||
std::string text;
|
||||
std::vector<InlineLink> inline_links;
|
||||
bool is_header;
|
||||
int colspan;
|
||||
int rowspan;
|
||||
};
|
||||
|
||||
struct TableRow {
|
||||
std::vector<TableCell> cells;
|
||||
};
|
||||
|
||||
struct Table {
|
||||
std::vector<TableRow> rows;
|
||||
bool has_header;
|
||||
};
|
||||
|
||||
struct Image {
|
||||
std::string src;
|
||||
std::string alt;
|
||||
int width; // -1 if not specified
|
||||
int height; // -1 if not specified
|
||||
};
|
||||
|
||||
struct FormField {
|
||||
enum Type { TEXT, PASSWORD, CHECKBOX, RADIO, SUBMIT, BUTTON } type;
|
||||
std::string name;
|
||||
std::string value;
|
||||
std::string placeholder;
|
||||
bool checked;
|
||||
};
|
||||
|
||||
struct Form {
|
||||
std::string action;
|
||||
std::string method;
|
||||
std::vector<FormField> fields;
|
||||
};
|
||||
|
||||
struct ContentElement {
|
||||
ElementType type;
|
||||
std::string text;
|
||||
std::string url;
|
||||
int level;
|
||||
int list_number; // For ordered lists
|
||||
int nesting_level; // For nested lists
|
||||
std::vector<InlineLink> inline_links; // Links within this element's text
|
||||
|
||||
// Extended content types
|
||||
Table table_data;
|
||||
Image image_data;
|
||||
Form form_data;
|
||||
};
|
||||
|
||||
struct ParsedDocument {
|
||||
std::string title;
|
||||
std::string url;
|
||||
std::vector<ContentElement> elements;
|
||||
std::vector<Link> links;
|
||||
};
|
||||
|
||||
class HtmlParser {
|
||||
public:
|
||||
HtmlParser();
|
||||
~HtmlParser();
|
||||
|
||||
// 新接口:使用DOM树解析
|
||||
DocumentTree parse_tree(const std::string& html, const std::string& base_url = "");
|
||||
|
||||
// 旧接口:保持向后兼容(已废弃,内部使用parse_tree)
|
||||
ParsedDocument parse(const std::string& html, const std::string& base_url = "");
|
||||
|
||||
void set_keep_code_blocks(bool keep);
|
||||
void set_keep_lists(bool keep);
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
|
@ -1,631 +0,0 @@
|
|||
#include "http_client.h"
|
||||
#include <curl/curl.h>
|
||||
#include <stdexcept>
|
||||
|
||||
// 回调函数用于接收文本数据
|
||||
static size_t write_callback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
||||
size_t total_size = size * nmemb;
|
||||
userp->append(static_cast<char*>(contents), total_size);
|
||||
return total_size;
|
||||
}
|
||||
|
||||
// 回调函数用于接收二进制数据
|
||||
static size_t binary_write_callback(void* contents, size_t size, size_t nmemb, std::vector<uint8_t>* userp) {
|
||||
size_t total_size = size * nmemb;
|
||||
uint8_t* data = static_cast<uint8_t*>(contents);
|
||||
userp->insert(userp->end(), data, data + total_size);
|
||||
return total_size;
|
||||
}
|
||||
|
||||
// 内部图片下载任务
|
||||
struct InternalImageTask {
|
||||
CURL* easy_handle = nullptr;
|
||||
std::string url;
|
||||
void* user_data = nullptr;
|
||||
std::vector<uint8_t> data;
|
||||
bool is_loading = false;
|
||||
};
|
||||
|
||||
class HttpClient::Impl {
|
||||
public:
|
||||
CURL* curl;
|
||||
long timeout;
|
||||
std::string user_agent;
|
||||
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;
|
||||
|
||||
// 异步图片下载相关
|
||||
CURLM* image_multi = nullptr; // 专用于图片的multi handle
|
||||
std::vector<InternalImageTask> pending_images; // 待下载队列
|
||||
std::vector<InternalImageTask> loading_images; // 正在下载
|
||||
std::vector<ImageDownloadTask> completed_images; // 已完成
|
||||
int max_concurrent_images = 3;
|
||||
|
||||
Impl() : timeout(30),
|
||||
user_agent("TUT-Browser/2.0 (Terminal User Interface Browser)"),
|
||||
follow_redirects(true) {
|
||||
curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
throw std::runtime_error("Failed to initialize CURL");
|
||||
}
|
||||
// Enable cookie engine by default (in-memory)
|
||||
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");
|
||||
}
|
||||
|
||||
// 初始化image multi handle用于图片下载
|
||||
image_multi = curl_multi_init();
|
||||
if (!image_multi) {
|
||||
throw std::runtime_error("Failed to initialize image CURL multi handle");
|
||||
}
|
||||
}
|
||||
|
||||
~Impl() {
|
||||
// 清理异步请求
|
||||
cleanup_async();
|
||||
cleanup_all_images();
|
||||
|
||||
if (image_multi) {
|
||||
curl_multi_cleanup(image_multi);
|
||||
}
|
||||
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, "");
|
||||
}
|
||||
|
||||
void cleanup_all_images() {
|
||||
// 清理所有正在加载的图片
|
||||
for (auto& task : loading_images) {
|
||||
if (task.easy_handle) {
|
||||
curl_multi_remove_handle(image_multi, task.easy_handle);
|
||||
curl_easy_cleanup(task.easy_handle);
|
||||
}
|
||||
}
|
||||
loading_images.clear();
|
||||
|
||||
// 清理待下载的图片
|
||||
for (auto& task : pending_images) {
|
||||
if (task.easy_handle) {
|
||||
curl_easy_cleanup(task.easy_handle);
|
||||
}
|
||||
}
|
||||
pending_images.clear();
|
||||
completed_images.clear();
|
||||
}
|
||||
|
||||
// 启动一个图片下载任务
|
||||
void start_image_download(InternalImageTask& task) {
|
||||
task.easy_handle = curl_easy_init();
|
||||
if (!task.easy_handle) {
|
||||
return; // 跳过失败的任务
|
||||
}
|
||||
|
||||
// 配置请求
|
||||
setup_easy_handle(task.easy_handle, task.url);
|
||||
|
||||
// 设置写回调
|
||||
task.data.clear();
|
||||
curl_easy_setopt(task.easy_handle, CURLOPT_WRITEFUNCTION, binary_write_callback);
|
||||
curl_easy_setopt(task.easy_handle, CURLOPT_WRITEDATA, &task.data);
|
||||
|
||||
// 添加到multi handle
|
||||
curl_multi_add_handle(image_multi, task.easy_handle);
|
||||
task.is_loading = true;
|
||||
}
|
||||
};
|
||||
|
||||
HttpClient::HttpClient() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
||||
HttpClient::~HttpClient() = default;
|
||||
|
||||
HttpResponse HttpClient::fetch(const std::string& url) {
|
||||
HttpResponse response;
|
||||
response.status_code = 0;
|
||||
|
||||
if (!pImpl->curl) {
|
||||
response.error_message = "CURL not initialized";
|
||||
return response;
|
||||
}
|
||||
|
||||
// 重置选项 (Note: curl_easy_reset clears cookies setting if not careful,
|
||||
// but here we might want to preserve them or reset and re-apply options)
|
||||
// Actually curl_easy_reset clears ALL options including cookie engine state?
|
||||
// No, it resets options to default. It does NOT clear the cookie engine state (cookies held in memory).
|
||||
// BUT it resets CURLOPT_COOKIEFILE/JAR settings.
|
||||
|
||||
curl_easy_reset(pImpl->curl);
|
||||
|
||||
// Re-apply settings
|
||||
// 设置URL
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
|
||||
|
||||
// 设置写回调
|
||||
std::string response_body;
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEDATA, &response_body);
|
||||
|
||||
// 设置超时
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_TIMEOUT, pImpl->timeout);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
|
||||
// 设置用户代理
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_USERAGENT, pImpl->user_agent.c_str());
|
||||
|
||||
// 设置是否跟随重定向
|
||||
if (pImpl->follow_redirects) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_MAXREDIRS, 10L);
|
||||
}
|
||||
|
||||
// 支持 HTTPS
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
// Cookie settings
|
||||
if (!pImpl->cookie_file.empty()) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, pImpl->cookie_file.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEJAR, pImpl->cookie_file.c_str());
|
||||
} else {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, "");
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
CURLcode res = curl_easy_perform(pImpl->curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
response.error_message = curl_easy_strerror(res);
|
||||
return response;
|
||||
}
|
||||
|
||||
// 获取响应码
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
response.status_code = static_cast<int>(http_code);
|
||||
|
||||
// 获取 Content-Type
|
||||
char* content_type = nullptr;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_CONTENT_TYPE, &content_type);
|
||||
if (content_type) {
|
||||
response.content_type = content_type;
|
||||
}
|
||||
|
||||
response.body = std::move(response_body);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
BinaryResponse HttpClient::fetch_binary(const std::string& url) {
|
||||
BinaryResponse response;
|
||||
response.status_code = 0;
|
||||
|
||||
if (!pImpl->curl) {
|
||||
response.error_message = "CURL not initialized";
|
||||
return response;
|
||||
}
|
||||
|
||||
curl_easy_reset(pImpl->curl);
|
||||
|
||||
// 设置URL
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
|
||||
|
||||
// 设置写回调
|
||||
std::vector<uint8_t> response_data;
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEFUNCTION, binary_write_callback);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEDATA, &response_data);
|
||||
|
||||
// 设置超时
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_TIMEOUT, pImpl->timeout);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
|
||||
// 设置用户代理
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_USERAGENT, pImpl->user_agent.c_str());
|
||||
|
||||
// 设置是否跟随重定向
|
||||
if (pImpl->follow_redirects) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_MAXREDIRS, 10L);
|
||||
}
|
||||
|
||||
// 支持 HTTPS
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
// Cookie settings
|
||||
if (!pImpl->cookie_file.empty()) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, pImpl->cookie_file.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEJAR, pImpl->cookie_file.c_str());
|
||||
} else {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, "");
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
CURLcode res = curl_easy_perform(pImpl->curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
response.error_message = curl_easy_strerror(res);
|
||||
return response;
|
||||
}
|
||||
|
||||
// 获取响应码
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
response.status_code = static_cast<int>(http_code);
|
||||
|
||||
// 获取 Content-Type
|
||||
char* content_type = nullptr;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_CONTENT_TYPE, &content_type);
|
||||
if (content_type) {
|
||||
response.content_type = content_type;
|
||||
}
|
||||
|
||||
response.data = std::move(response_data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
HttpResponse HttpClient::post(const std::string& url, const std::string& data,
|
||||
const std::string& content_type) {
|
||||
HttpResponse response;
|
||||
response.status_code = 0;
|
||||
|
||||
if (!pImpl->curl) {
|
||||
response.error_message = "CURL not initialized";
|
||||
return response;
|
||||
}
|
||||
|
||||
curl_easy_reset(pImpl->curl);
|
||||
|
||||
// Re-apply settings
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
|
||||
|
||||
// Set write callback
|
||||
std::string response_body;
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEDATA, &response_body);
|
||||
|
||||
// Set timeout
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_TIMEOUT, pImpl->timeout);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
|
||||
// Set user agent
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_USERAGENT, pImpl->user_agent.c_str());
|
||||
|
||||
// Set redirect following
|
||||
if (pImpl->follow_redirects) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_MAXREDIRS, 10L);
|
||||
}
|
||||
|
||||
// HTTPS support
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
// Cookie settings
|
||||
if (!pImpl->cookie_file.empty()) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, pImpl->cookie_file.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEJAR, pImpl->cookie_file.c_str());
|
||||
} else {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, "");
|
||||
}
|
||||
|
||||
// Enable automatic decompression
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_ACCEPT_ENCODING, "");
|
||||
|
||||
// Set POST method
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_POST, 1L);
|
||||
|
||||
// Set POST data
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_POSTFIELDS, data.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_POSTFIELDSIZE, data.length());
|
||||
|
||||
// Set Content-Type header
|
||||
struct curl_slist* headers = nullptr;
|
||||
std::string content_type_header = "Content-Type: " + content_type;
|
||||
headers = curl_slist_append(headers, content_type_header.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_HTTPHEADER, headers);
|
||||
|
||||
// Perform request
|
||||
CURLcode res = curl_easy_perform(pImpl->curl);
|
||||
|
||||
// Clean up headers
|
||||
curl_slist_free_all(headers);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
response.error_message = curl_easy_strerror(res);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Get response code
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
response.status_code = static_cast<int>(http_code);
|
||||
|
||||
// Get Content-Type
|
||||
char* resp_content_type = nullptr;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_CONTENT_TYPE, &resp_content_type);
|
||||
if (resp_content_type) {
|
||||
response.content_type = resp_content_type;
|
||||
}
|
||||
|
||||
response.body = std::move(response_body);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
void HttpClient::set_timeout(long timeout_seconds) {
|
||||
pImpl->timeout = timeout_seconds;
|
||||
}
|
||||
|
||||
void HttpClient::set_user_agent(const std::string& user_agent) {
|
||||
pImpl->user_agent = user_agent;
|
||||
}
|
||||
|
||||
void HttpClient::set_follow_redirects(bool follow) {
|
||||
pImpl->follow_redirects = 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;
|
||||
}
|
||||
|
||||
// ========== 异步图片下载接口 ==========
|
||||
|
||||
void HttpClient::add_image_download(const std::string& url, void* user_data) {
|
||||
InternalImageTask task;
|
||||
task.url = url;
|
||||
task.user_data = user_data;
|
||||
pImpl->pending_images.push_back(std::move(task));
|
||||
}
|
||||
|
||||
void HttpClient::poll_image_downloads() {
|
||||
// 启动新的下载任务,直到达到最大并发数
|
||||
while (!pImpl->pending_images.empty() &&
|
||||
static_cast<int>(pImpl->loading_images.size()) < pImpl->max_concurrent_images) {
|
||||
InternalImageTask task = std::move(pImpl->pending_images.front());
|
||||
pImpl->pending_images.erase(pImpl->pending_images.begin());
|
||||
|
||||
pImpl->start_image_download(task);
|
||||
pImpl->loading_images.push_back(std::move(task));
|
||||
}
|
||||
|
||||
if (pImpl->loading_images.empty()) {
|
||||
return; // 没有正在下载的任务
|
||||
}
|
||||
|
||||
// 执行非阻塞的multi perform
|
||||
int still_running = 0;
|
||||
CURLMcode mc = curl_multi_perform(pImpl->image_multi, &still_running);
|
||||
|
||||
if (mc != CURLM_OK) {
|
||||
// 发生错误,放弃所有正在下载的任务
|
||||
pImpl->cleanup_all_images();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有完成的请求
|
||||
int msgs_left = 0;
|
||||
CURLMsg* msg;
|
||||
std::vector<std::pair<CURL*, CURLcode>> to_remove; // 记录需要移除的handles和结果
|
||||
|
||||
while ((msg = curl_multi_info_read(pImpl->image_multi, &msgs_left))) {
|
||||
if (msg->msg == CURLMSG_DONE) {
|
||||
to_remove.push_back({msg->easy_handle, msg->data.result});
|
||||
}
|
||||
}
|
||||
|
||||
// 处理完成的任务
|
||||
for (const auto& [easy, curl_result] : to_remove) {
|
||||
// 找到对应的任务
|
||||
for (auto it = pImpl->loading_images.begin(); it != pImpl->loading_images.end(); ++it) {
|
||||
if (it->easy_handle == easy) {
|
||||
ImageDownloadTask completed;
|
||||
completed.url = it->url;
|
||||
completed.user_data = it->user_data;
|
||||
|
||||
if (curl_result == CURLE_OK) {
|
||||
// 获取响应信息
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
completed.status_code = static_cast<int>(http_code);
|
||||
|
||||
char* content_type = nullptr;
|
||||
curl_easy_getinfo(easy, CURLINFO_CONTENT_TYPE, &content_type);
|
||||
if (content_type) {
|
||||
completed.content_type = content_type;
|
||||
}
|
||||
|
||||
completed.data = std::move(it->data);
|
||||
} else {
|
||||
completed.error_message = curl_easy_strerror(curl_result);
|
||||
completed.status_code = 0;
|
||||
}
|
||||
|
||||
pImpl->completed_images.push_back(std::move(completed));
|
||||
|
||||
// 清理easy handle
|
||||
curl_multi_remove_handle(pImpl->image_multi, easy);
|
||||
curl_easy_cleanup(easy);
|
||||
|
||||
// 从loading列表中移除
|
||||
pImpl->loading_images.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<ImageDownloadTask> HttpClient::get_completed_images() {
|
||||
std::vector<ImageDownloadTask> result = std::move(pImpl->completed_images);
|
||||
pImpl->completed_images.clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
void HttpClient::cancel_all_images() {
|
||||
pImpl->cleanup_all_images();
|
||||
}
|
||||
|
||||
int HttpClient::get_pending_image_count() const {
|
||||
return static_cast<int>(pImpl->pending_images.size());
|
||||
}
|
||||
|
||||
int HttpClient::get_loading_image_count() const {
|
||||
return static_cast<int>(pImpl->loading_images.size());
|
||||
}
|
||||
|
||||
void HttpClient::set_max_concurrent_images(int max) {
|
||||
if (max > 0 && max <= 10) { // 限制在1-10之间
|
||||
pImpl->max_concurrent_images = max;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
// 异步请求状态
|
||||
enum class AsyncState {
|
||||
IDLE, // 无活跃请求
|
||||
LOADING, // 请求进行中
|
||||
COMPLETE, // 请求成功完成
|
||||
FAILED, // 请求失败
|
||||
CANCELLED // 请求被取消
|
||||
};
|
||||
|
||||
struct HttpResponse {
|
||||
int status_code;
|
||||
std::string body;
|
||||
std::string content_type;
|
||||
std::string error_message;
|
||||
|
||||
bool is_success() const {
|
||||
return status_code >= 200 && status_code < 300;
|
||||
}
|
||||
|
||||
bool is_image() const {
|
||||
return content_type.find("image/") == 0;
|
||||
}
|
||||
};
|
||||
|
||||
struct BinaryResponse {
|
||||
int status_code;
|
||||
std::vector<uint8_t> data;
|
||||
std::string content_type;
|
||||
std::string error_message;
|
||||
|
||||
bool is_success() const {
|
||||
return status_code >= 200 && status_code < 300;
|
||||
}
|
||||
};
|
||||
|
||||
// 异步图片下载任务
|
||||
struct ImageDownloadTask {
|
||||
std::string url;
|
||||
void* user_data; // 用户自定义数据 (例如 DomNode*)
|
||||
std::vector<uint8_t> data;
|
||||
std::string content_type;
|
||||
int status_code = 0;
|
||||
std::string error_message;
|
||||
|
||||
bool is_success() const {
|
||||
return status_code >= 200 && status_code < 300;
|
||||
}
|
||||
};
|
||||
|
||||
class HttpClient {
|
||||
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 add_image_download(const std::string& url, void* user_data = nullptr);
|
||||
void poll_image_downloads(); // 非阻塞轮询所有图片下载
|
||||
std::vector<ImageDownloadTask> get_completed_images(); // 获取并移除已完成的图片
|
||||
void cancel_all_images(); // 取消所有图片下载
|
||||
int get_pending_image_count() const; // 获取待下载图片数量
|
||||
int get_loading_image_count() const; // 获取正在下载的图片数量
|
||||
void set_max_concurrent_images(int max); // 设置最大并发数 (默认3)
|
||||
|
||||
// 配置
|
||||
void set_timeout(long timeout_seconds);
|
||||
void set_user_agent(const std::string& user_agent);
|
||||
void set_follow_redirects(bool follow);
|
||||
void enable_cookies(const std::string& cookie_file = "");
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
|
@ -1,470 +0,0 @@
|
|||
#include "input_handler.h"
|
||||
#include <curses.h>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
class InputHandler::Impl {
|
||||
public:
|
||||
InputMode mode = InputMode::NORMAL;
|
||||
std::string buffer;
|
||||
std::string count_buffer;
|
||||
std::function<void(const std::string&)> status_callback;
|
||||
|
||||
void set_status(const std::string& msg) {
|
||||
if (status_callback) {
|
||||
status_callback(msg);
|
||||
}
|
||||
}
|
||||
|
||||
InputResult process_normal_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
result.number = 0;
|
||||
result.has_count = false;
|
||||
result.count = 1;
|
||||
|
||||
// Handle multi-char commands like 'gg', 'm', '
|
||||
if (!buffer.empty()) {
|
||||
if (buffer == "m") {
|
||||
// Set mark with letter
|
||||
if (std::isalpha(ch)) {
|
||||
result.action = Action::SET_MARK;
|
||||
result.text = std::string(1, static_cast<char>(ch));
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
}
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
} else if (buffer == "'") {
|
||||
// Jump to mark
|
||||
if (std::isalpha(ch)) {
|
||||
result.action = Action::GOTO_MARK;
|
||||
result.text = std::string(1, static_cast<char>(ch));
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
}
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle digit input for count
|
||||
if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) {
|
||||
count_buffer += static_cast<char>(ch);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!count_buffer.empty()) {
|
||||
result.has_count = true;
|
||||
result.count = std::stoi(count_buffer);
|
||||
}
|
||||
|
||||
switch (ch) {
|
||||
case 'j':
|
||||
case KEY_DOWN:
|
||||
result.action = Action::SCROLL_DOWN;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'k':
|
||||
case KEY_UP:
|
||||
result.action = Action::SCROLL_UP;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'h':
|
||||
case KEY_LEFT:
|
||||
result.action = Action::GO_BACK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'l':
|
||||
case KEY_RIGHT:
|
||||
result.action = Action::GO_FORWARD;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 4:
|
||||
case ' ':
|
||||
result.action = Action::SCROLL_PAGE_DOWN;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 21:
|
||||
case 'b':
|
||||
result.action = Action::SCROLL_PAGE_UP;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'g':
|
||||
buffer += 'g';
|
||||
if (buffer == "gg") {
|
||||
result.action = Action::GOTO_TOP;
|
||||
buffer.clear();
|
||||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'G':
|
||||
if (result.has_count) {
|
||||
result.action = Action::GOTO_LINE;
|
||||
result.number = result.count;
|
||||
} else {
|
||||
result.action = Action::GOTO_BOTTOM;
|
||||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '/':
|
||||
mode = InputMode::SEARCH;
|
||||
buffer = "/";
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'n':
|
||||
result.action = Action::SEARCH_NEXT;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'N':
|
||||
result.action = Action::SEARCH_PREV;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '\t':
|
||||
// Tab can navigate both links and fields - browser will decide
|
||||
result.action = Action::NEXT_LINK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case KEY_BTAB:
|
||||
case 'T':
|
||||
result.action = Action::PREV_LINK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '\n':
|
||||
case '\r':
|
||||
// Enter can follow links or activate fields - browser will decide
|
||||
if (result.has_count) {
|
||||
result.action = Action::GOTO_LINK;
|
||||
result.number = result.count;
|
||||
} else {
|
||||
result.action = Action::FOLLOW_LINK;
|
||||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'i':
|
||||
// 'i' to focus on first form field (like vim insert mode)
|
||||
result.action = Action::NEXT_FIELD;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'f':
|
||||
// 'f' command: vimium-style link hints
|
||||
result.action = Action::SHOW_LINK_HINTS;
|
||||
mode = InputMode::LINK_HINTS;
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'm':
|
||||
// Set mark (wait for next char)
|
||||
buffer = "m";
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '\'':
|
||||
// Jump to mark (wait for next char)
|
||||
buffer = "'";
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case ':':
|
||||
mode = InputMode::COMMAND;
|
||||
buffer = ":";
|
||||
break;
|
||||
case 'r':
|
||||
result.action = Action::REFRESH;
|
||||
break;
|
||||
case 'q':
|
||||
result.action = Action::QUIT;
|
||||
break;
|
||||
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;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_command_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
std::string command = buffer.substr(1);
|
||||
|
||||
if (command == "q" || command == "quit") {
|
||||
result.action = Action::QUIT;
|
||||
} else if (command == "h" || command == "help") {
|
||||
result.action = Action::HELP;
|
||||
} else if (command == "r" || command == "refresh") {
|
||||
result.action = Action::REFRESH;
|
||||
} else if (command.rfind("o ", 0) == 0 || command.rfind("open ", 0) == 0) {
|
||||
size_t space_pos = command.find(' ');
|
||||
if (space_pos != std::string::npos) {
|
||||
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;
|
||||
result.number = std::stoi(command);
|
||||
} catch (...) {
|
||||
set_status("Invalid line number");
|
||||
}
|
||||
}
|
||||
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == 27) {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
if (buffer.length() > 1) {
|
||||
buffer.pop_back();
|
||||
} else {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
}
|
||||
} else if (std::isprint(ch)) {
|
||||
buffer += static_cast<char>(ch);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_search_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
if (buffer.length() > 1) {
|
||||
result.action = Action::SEARCH_FORWARD;
|
||||
result.text = buffer.substr(1);
|
||||
}
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == 27) {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
if (buffer.length() > 1) {
|
||||
buffer.pop_back();
|
||||
} else {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
}
|
||||
} else if (std::isprint(ch)) {
|
||||
buffer += static_cast<char>(ch);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_link_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (std::isdigit(ch)) {
|
||||
buffer += static_cast<char>(ch);
|
||||
} else if (ch == '\n' || ch == '\r') {
|
||||
// Follow the link number entered
|
||||
if (buffer.length() > 1) {
|
||||
try {
|
||||
int link_num = std::stoi(buffer.substr(1));
|
||||
result.action = Action::FOLLOW_LINK_NUM;
|
||||
result.number = link_num;
|
||||
} catch (...) {
|
||||
set_status("Invalid link number");
|
||||
}
|
||||
}
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == 27) {
|
||||
// ESC cancels
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
if (buffer.length() > 1) {
|
||||
buffer.pop_back();
|
||||
} else {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_link_hints_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == 27) {
|
||||
// ESC cancels link hints mode
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
// Backspace removes last character
|
||||
if (!buffer.empty()) {
|
||||
buffer.pop_back();
|
||||
} else {
|
||||
mode = InputMode::NORMAL;
|
||||
}
|
||||
return result;
|
||||
} else if (std::isalpha(ch)) {
|
||||
// Add character to buffer
|
||||
buffer += std::tolower(static_cast<char>(ch));
|
||||
|
||||
// Try to match link hint
|
||||
result.action = Action::FOLLOW_LINK_HINT;
|
||||
result.text = buffer;
|
||||
|
||||
// Mode will be reset by browser if link is followed
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_form_edit_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == 27) {
|
||||
// ESC exits form edit mode
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (ch == '\n' || ch == '\r') {
|
||||
// Enter submits the text
|
||||
result.action = Action::EDIT_TEXT;
|
||||
result.text = buffer;
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
// Backspace removes last character
|
||||
if (!buffer.empty()) {
|
||||
buffer.pop_back();
|
||||
}
|
||||
return result;
|
||||
} else if (ch == '\t') {
|
||||
// Tab moves to next field while saving current text
|
||||
result.action = Action::NEXT_FIELD;
|
||||
result.text = buffer;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (ch == KEY_BTAB) {
|
||||
// Shift+Tab moves to previous field while saving current text
|
||||
result.action = Action::PREV_FIELD;
|
||||
result.text = buffer;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (std::isprint(ch)) {
|
||||
// Add printable characters to buffer
|
||||
buffer += static_cast<char>(ch);
|
||||
// Return EDIT_TEXT to update in real-time
|
||||
result.action = Action::EDIT_TEXT;
|
||||
result.text = buffer;
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_select_option_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == 27) {
|
||||
// ESC cancels selection
|
||||
mode = InputMode::NORMAL;
|
||||
return result;
|
||||
} else if (ch == '\n' || ch == '\r') {
|
||||
// Enter selects current option
|
||||
result.action = Action::SELECT_CURRENT_OPTION;
|
||||
mode = InputMode::NORMAL;
|
||||
return result;
|
||||
} else if (ch == 'j' || ch == KEY_DOWN) {
|
||||
// Next option
|
||||
result.action = Action::NEXT_OPTION;
|
||||
return result;
|
||||
} else if (ch == 'k' || ch == KEY_UP) {
|
||||
// Previous option
|
||||
result.action = Action::PREV_OPTION;
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
||||
InputHandler::~InputHandler() = default;
|
||||
|
||||
InputResult InputHandler::handle_key(int ch) {
|
||||
switch (pImpl->mode) {
|
||||
case InputMode::NORMAL:
|
||||
return pImpl->process_normal_mode(ch);
|
||||
case InputMode::COMMAND:
|
||||
return pImpl->process_command_mode(ch);
|
||||
case InputMode::SEARCH:
|
||||
return pImpl->process_search_mode(ch);
|
||||
case InputMode::LINK:
|
||||
return pImpl->process_link_mode(ch);
|
||||
case InputMode::LINK_HINTS:
|
||||
return pImpl->process_link_hints_mode(ch);
|
||||
case InputMode::FORM_EDIT:
|
||||
return pImpl->process_form_edit_mode(ch);
|
||||
case InputMode::SELECT_OPTION:
|
||||
return pImpl->process_select_option_mode(ch);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
return result;
|
||||
}
|
||||
|
||||
InputMode InputHandler::get_mode() const {
|
||||
return pImpl->mode;
|
||||
}
|
||||
|
||||
std::string InputHandler::get_buffer() const {
|
||||
return pImpl->buffer;
|
||||
}
|
||||
|
||||
void InputHandler::reset() {
|
||||
pImpl->mode = InputMode::NORMAL;
|
||||
pImpl->buffer.clear();
|
||||
pImpl->count_buffer.clear();
|
||||
}
|
||||
|
||||
void InputHandler::set_mode(InputMode mode) {
|
||||
pImpl->mode = mode;
|
||||
}
|
||||
|
||||
void InputHandler::set_buffer(const std::string& buffer) {
|
||||
pImpl->buffer = buffer;
|
||||
}
|
||||
|
||||
void InputHandler::set_status_callback(std::function<void(const std::string&)> callback) {
|
||||
pImpl->status_callback = callback;
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
enum class InputMode {
|
||||
NORMAL,
|
||||
COMMAND,
|
||||
SEARCH,
|
||||
LINK,
|
||||
LINK_HINTS, // Vimium-style 'f' mode
|
||||
FORM_EDIT, // Form field editing mode
|
||||
SELECT_OPTION // Dropdown selection mode
|
||||
};
|
||||
|
||||
enum class Action {
|
||||
NONE,
|
||||
SCROLL_UP,
|
||||
SCROLL_DOWN,
|
||||
SCROLL_PAGE_UP,
|
||||
SCROLL_PAGE_DOWN,
|
||||
GOTO_TOP,
|
||||
GOTO_BOTTOM,
|
||||
GOTO_LINE,
|
||||
SEARCH_FORWARD,
|
||||
SEARCH_NEXT,
|
||||
SEARCH_PREV,
|
||||
NEXT_LINK,
|
||||
PREV_LINK,
|
||||
FOLLOW_LINK,
|
||||
GOTO_LINK, // Jump to specific link by number
|
||||
FOLLOW_LINK_NUM, // Follow specific link by number (f command)
|
||||
SHOW_LINK_HINTS, // Activate link hints mode ('f')
|
||||
FOLLOW_LINK_HINT, // Follow link by hint letters
|
||||
GO_BACK,
|
||||
GO_FORWARD,
|
||||
OPEN_URL,
|
||||
REFRESH,
|
||||
QUIT,
|
||||
HELP,
|
||||
SET_MARK, // Set a mark (m + 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)
|
||||
NEXT_FIELD, // Move to next form field (Tab)
|
||||
PREV_FIELD, // Move to previous form field (Shift+Tab)
|
||||
ACTIVATE_FIELD, // Activate current field for editing (Enter)
|
||||
TOGGLE_CHECKBOX, // Toggle checkbox state
|
||||
EDIT_TEXT, // Edit text input (updates text buffer)
|
||||
SUBMIT_FORM, // Submit form (Enter on submit button)
|
||||
NEXT_OPTION, // Move to next dropdown option (j/down)
|
||||
PREV_OPTION, // Move to previous dropdown option (k/up)
|
||||
SELECT_CURRENT_OPTION // Select current dropdown option (Enter)
|
||||
};
|
||||
|
||||
struct InputResult {
|
||||
Action action;
|
||||
std::string text;
|
||||
int number;
|
||||
bool has_count;
|
||||
int count;
|
||||
};
|
||||
|
||||
class InputHandler {
|
||||
public:
|
||||
InputHandler();
|
||||
~InputHandler();
|
||||
|
||||
InputResult handle_key(int ch);
|
||||
InputMode get_mode() const;
|
||||
std::string get_buffer() const;
|
||||
void reset();
|
||||
void set_mode(InputMode mode);
|
||||
void set_buffer(const std::string& buffer);
|
||||
void set_status_callback(std::function<void(const std::string&)> callback);
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* 颜色定义 - True Color (24-bit RGB)
|
||||
*
|
||||
* 使用温暖的配色方案,适合长时间阅读
|
||||
*/
|
||||
namespace colors {
|
||||
|
||||
// ==================== 基础颜色 ====================
|
||||
|
||||
// 背景色
|
||||
constexpr uint32_t BG_PRIMARY = 0x1A1A1A; // 主背景 - 深灰
|
||||
constexpr uint32_t BG_SECONDARY = 0x252525; // 次背景 - 稍浅灰
|
||||
constexpr uint32_t BG_ELEVATED = 0x2A2A2A; // 抬升背景 - 用于卡片/区块
|
||||
constexpr uint32_t BG_SELECTION = 0x3A3A3A; // 选中背景
|
||||
|
||||
// 前景色
|
||||
constexpr uint32_t FG_PRIMARY = 0xD0D0D0; // 主文本 - 浅灰
|
||||
constexpr uint32_t FG_SECONDARY = 0x909090; // 次文本 - 中灰
|
||||
constexpr uint32_t FG_DIM = 0x606060; // 暗淡文本
|
||||
|
||||
// ==================== 语义颜色 ====================
|
||||
|
||||
// 标题
|
||||
constexpr uint32_t H1_FG = 0xE8C48C; // H1 - 暖金色
|
||||
constexpr uint32_t H2_FG = 0x88C0D0; // H2 - 冰蓝色
|
||||
constexpr uint32_t H3_FG = 0xA3BE8C; // H3 - 柔绿色
|
||||
|
||||
// 链接
|
||||
constexpr uint32_t LINK_FG = 0x81A1C1; // 链接 - 柔蓝色
|
||||
constexpr uint32_t LINK_ACTIVE = 0x88C0D0; // 活跃链接 - 亮蓝色
|
||||
constexpr uint32_t LINK_VISITED = 0xB48EAD; // 已访问链接 - 柔紫色
|
||||
|
||||
// 表单元素
|
||||
constexpr uint32_t INPUT_BG = 0x2E3440; // 输入框背景
|
||||
constexpr uint32_t INPUT_BORDER = 0x4C566A; // 输入框边框
|
||||
constexpr uint32_t INPUT_FOCUS = 0x5E81AC; // 聚焦边框
|
||||
|
||||
// 状态颜色
|
||||
constexpr uint32_t SUCCESS = 0xA3BE8C; // 成功 - 绿色
|
||||
constexpr uint32_t WARNING = 0xEBCB8B; // 警告 - 黄色
|
||||
constexpr uint32_t ERROR = 0xBF616A; // 错误 - 红色
|
||||
constexpr uint32_t INFO = 0x88C0D0; // 信息 - 蓝色
|
||||
|
||||
// ==================== UI元素颜色 ====================
|
||||
|
||||
// 状态栏
|
||||
constexpr uint32_t STATUSBAR_BG = 0x2E3440; // 状态栏背景
|
||||
constexpr uint32_t STATUSBAR_FG = 0xD8DEE9; // 状态栏文本
|
||||
|
||||
// URL栏
|
||||
constexpr uint32_t URLBAR_BG = 0x3B4252; // URL栏背景
|
||||
constexpr uint32_t URLBAR_FG = 0xECEFF4; // URL栏文本
|
||||
|
||||
// 搜索高亮
|
||||
constexpr uint32_t SEARCH_MATCH_BG = 0x4C566A;
|
||||
constexpr uint32_t SEARCH_MATCH_FG = 0xECEFF4;
|
||||
constexpr uint32_t SEARCH_CURRENT_BG = 0x5E81AC;
|
||||
constexpr uint32_t SEARCH_CURRENT_FG = 0xFFFFFF;
|
||||
|
||||
// 装饰元素
|
||||
constexpr uint32_t BORDER = 0x4C566A; // 边框
|
||||
constexpr uint32_t DIVIDER = 0x3B4252; // 分隔线
|
||||
|
||||
// 代码块
|
||||
constexpr uint32_t CODE_BG = 0x2E3440; // 代码背景
|
||||
constexpr uint32_t CODE_FG = 0xD8DEE9; // 代码文本
|
||||
|
||||
// 引用块
|
||||
constexpr uint32_t QUOTE_BORDER = 0x4C566A; // 引用边框
|
||||
constexpr uint32_t QUOTE_FG = 0x909090; // 引用文本
|
||||
|
||||
// 表格
|
||||
constexpr uint32_t TABLE_BORDER = 0x4C566A;
|
||||
constexpr uint32_t TABLE_HEADER_BG = 0x2E3440;
|
||||
constexpr uint32_t TABLE_ROW_ALT = 0x252525; // 交替行
|
||||
|
||||
} // namespace colors
|
||||
|
||||
/**
|
||||
* RGB辅助函数
|
||||
*/
|
||||
inline constexpr uint32_t rgb(uint8_t r, uint8_t g, uint8_t b) {
|
||||
return (static_cast<uint32_t>(r) << 16) |
|
||||
(static_cast<uint32_t>(g) << 8) |
|
||||
static_cast<uint32_t>(b);
|
||||
}
|
||||
|
||||
inline constexpr uint8_t get_red(uint32_t color) {
|
||||
return (color >> 16) & 0xFF;
|
||||
}
|
||||
|
||||
inline constexpr uint8_t get_green(uint32_t color) {
|
||||
return (color >> 8) & 0xFF;
|
||||
}
|
||||
|
||||
inline constexpr uint8_t get_blue(uint32_t color) {
|
||||
return color & 0xFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* 颜色混合(线性插值)
|
||||
*/
|
||||
inline uint32_t blend_colors(uint32_t c1, uint32_t c2, float t) {
|
||||
uint8_t r = static_cast<uint8_t>(get_red(c1) * (1 - t) + get_red(c2) * t);
|
||||
uint8_t g = static_cast<uint8_t>(get_green(c1) * (1 - t) + get_green(c2) * t);
|
||||
uint8_t b = static_cast<uint8_t>(get_blue(c1) * (1 - t) + get_blue(c2) * t);
|
||||
return rgb(r, g, b);
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* Unicode装饰字符
|
||||
*
|
||||
* 用于绘制边框、列表符号等装饰元素
|
||||
*/
|
||||
namespace chars {
|
||||
|
||||
// ==================== 框线字符 (Box Drawing) ====================
|
||||
|
||||
// 双线框
|
||||
constexpr const char* DBL_HORIZONTAL = "═";
|
||||
constexpr const char* DBL_VERTICAL = "║";
|
||||
constexpr const char* DBL_TOP_LEFT = "╔";
|
||||
constexpr const char* DBL_TOP_RIGHT = "╗";
|
||||
constexpr const char* DBL_BOTTOM_LEFT = "╚";
|
||||
constexpr const char* DBL_BOTTOM_RIGHT = "╝";
|
||||
constexpr const char* DBL_T_DOWN = "╦";
|
||||
constexpr const char* DBL_T_UP = "╩";
|
||||
constexpr const char* DBL_T_RIGHT = "╠";
|
||||
constexpr const char* DBL_T_LEFT = "╣";
|
||||
constexpr const char* DBL_CROSS = "╬";
|
||||
|
||||
// 单线框
|
||||
constexpr const char* SGL_HORIZONTAL = "─";
|
||||
constexpr const char* SGL_VERTICAL = "│";
|
||||
constexpr const char* SGL_TOP_LEFT = "┌";
|
||||
constexpr const char* SGL_TOP_RIGHT = "┐";
|
||||
constexpr const char* SGL_BOTTOM_LEFT = "└";
|
||||
constexpr const char* SGL_BOTTOM_RIGHT = "┘";
|
||||
constexpr const char* SGL_T_DOWN = "┬";
|
||||
constexpr const char* SGL_T_UP = "┴";
|
||||
constexpr const char* SGL_T_RIGHT = "├";
|
||||
constexpr const char* SGL_T_LEFT = "┤";
|
||||
constexpr const char* SGL_CROSS = "┼";
|
||||
|
||||
// 粗线框
|
||||
constexpr const char* HEAVY_HORIZONTAL = "━";
|
||||
constexpr const char* HEAVY_VERTICAL = "┃";
|
||||
constexpr const char* HEAVY_TOP_LEFT = "┏";
|
||||
constexpr const char* HEAVY_TOP_RIGHT = "┓";
|
||||
constexpr const char* HEAVY_BOTTOM_LEFT = "┗";
|
||||
constexpr const char* HEAVY_BOTTOM_RIGHT= "┛";
|
||||
|
||||
// 圆角框
|
||||
constexpr const char* ROUND_TOP_LEFT = "╭";
|
||||
constexpr const char* ROUND_TOP_RIGHT = "╮";
|
||||
constexpr const char* ROUND_BOTTOM_LEFT = "╰";
|
||||
constexpr const char* ROUND_BOTTOM_RIGHT= "╯";
|
||||
|
||||
// ==================== 列表符号 ====================
|
||||
|
||||
constexpr const char* BULLET = "•";
|
||||
constexpr const char* BULLET_HOLLOW = "◦";
|
||||
constexpr const char* BULLET_SQUARE = "▪";
|
||||
constexpr const char* CIRCLE = "◦";
|
||||
constexpr const char* SQUARE = "▪";
|
||||
constexpr const char* TRIANGLE = "‣";
|
||||
constexpr const char* DIAMOND = "◆";
|
||||
constexpr const char* QUOTE_LEFT = "│";
|
||||
constexpr const char* ARROW = "➤";
|
||||
constexpr const char* DASH = "–";
|
||||
constexpr const char* STAR = "★";
|
||||
constexpr const char* CHECK = "✓";
|
||||
constexpr const char* CROSS = "✗";
|
||||
|
||||
// ==================== 箭头 ====================
|
||||
|
||||
constexpr const char* ARROW_RIGHT = "→";
|
||||
constexpr const char* ARROW_LEFT = "←";
|
||||
constexpr const char* ARROW_UP = "↑";
|
||||
constexpr const char* ARROW_DOWN = "↓";
|
||||
constexpr const char* ARROW_DOUBLE_RIGHT= "»";
|
||||
constexpr const char* ARROW_DOUBLE_LEFT = "«";
|
||||
|
||||
// ==================== 装饰符号 ====================
|
||||
|
||||
constexpr const char* SECTION = "§";
|
||||
constexpr const char* PARAGRAPH = "¶";
|
||||
constexpr const char* ELLIPSIS = "…";
|
||||
constexpr const char* MIDDOT = "·";
|
||||
constexpr const char* DEGREE = "°";
|
||||
|
||||
// ==================== 进度/状态 ====================
|
||||
|
||||
constexpr const char* BLOCK_FULL = "█";
|
||||
constexpr const char* BLOCK_3_4 = "▓";
|
||||
constexpr const char* BLOCK_HALF = "▒";
|
||||
constexpr const char* BLOCK_1_4 = "░";
|
||||
constexpr const char* SPINNER[] = {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"};
|
||||
constexpr int SPINNER_FRAMES = 10;
|
||||
|
||||
// ==================== 分隔线样式 ====================
|
||||
|
||||
constexpr const char* HR_LIGHT = "─";
|
||||
constexpr const char* HR_HEAVY = "━";
|
||||
constexpr const char* HR_DOUBLE = "═";
|
||||
constexpr const char* HR_DASHED = "╌";
|
||||
constexpr const char* HR_DOTTED = "┄";
|
||||
|
||||
} // namespace chars
|
||||
|
||||
/**
|
||||
* 生成水平分隔线
|
||||
*/
|
||||
inline std::string make_horizontal_line(int width, const char* ch = chars::SGL_HORIZONTAL) {
|
||||
std::string result;
|
||||
for (int i = 0; i < width; i++) {
|
||||
result += ch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制简单边框(单线)
|
||||
*/
|
||||
struct BoxChars {
|
||||
const char* top_left;
|
||||
const char* top_right;
|
||||
const char* bottom_left;
|
||||
const char* bottom_right;
|
||||
const char* horizontal;
|
||||
const char* vertical;
|
||||
};
|
||||
|
||||
constexpr BoxChars BOX_SINGLE = {
|
||||
chars::SGL_TOP_LEFT, chars::SGL_TOP_RIGHT,
|
||||
chars::SGL_BOTTOM_LEFT, chars::SGL_BOTTOM_RIGHT,
|
||||
chars::SGL_HORIZONTAL, chars::SGL_VERTICAL
|
||||
};
|
||||
|
||||
constexpr BoxChars BOX_DOUBLE = {
|
||||
chars::DBL_TOP_LEFT, chars::DBL_TOP_RIGHT,
|
||||
chars::DBL_BOTTOM_LEFT, chars::DBL_BOTTOM_RIGHT,
|
||||
chars::DBL_HORIZONTAL, chars::DBL_VERTICAL
|
||||
};
|
||||
|
||||
constexpr BoxChars BOX_HEAVY = {
|
||||
chars::HEAVY_TOP_LEFT, chars::HEAVY_TOP_RIGHT,
|
||||
chars::HEAVY_BOTTOM_LEFT, chars::HEAVY_BOTTOM_RIGHT,
|
||||
chars::HEAVY_HORIZONTAL, chars::HEAVY_VERTICAL
|
||||
};
|
||||
|
||||
constexpr BoxChars BOX_ROUND = {
|
||||
chars::ROUND_TOP_LEFT, chars::ROUND_TOP_RIGHT,
|
||||
chars::ROUND_BOTTOM_LEFT, chars::ROUND_BOTTOM_RIGHT,
|
||||
chars::SGL_HORIZONTAL, chars::SGL_VERTICAL
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,265 +0,0 @@
|
|||
#include "image.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
// 尝试加载stb_image(如果存在)
|
||||
#if __has_include("../utils/stb_image.h")
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#include "../utils/stb_image.h"
|
||||
#define HAS_STB_IMAGE 1
|
||||
#else
|
||||
#define HAS_STB_IMAGE 0
|
||||
#endif
|
||||
|
||||
// 简单的PPM格式解码器(不需要外部库)
|
||||
static tut::ImageData decode_ppm(const std::vector<uint8_t>& data) {
|
||||
tut::ImageData result;
|
||||
|
||||
if (data.size() < 10) return result;
|
||||
|
||||
// 检查PPM magic number
|
||||
if (data[0] != 'P' || (data[1] != '6' && data[1] != '3')) {
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string header(data.begin(), data.begin() + std::min(data.size(), size_t(256)));
|
||||
std::istringstream iss(header);
|
||||
|
||||
std::string magic;
|
||||
int width, height, max_val;
|
||||
iss >> magic >> width >> height >> max_val;
|
||||
|
||||
if (width <= 0 || height <= 0 || max_val <= 0) return result;
|
||||
|
||||
result.width = width;
|
||||
result.height = height;
|
||||
result.channels = 4; // 输出RGBA
|
||||
|
||||
// 找到header结束位置
|
||||
size_t header_end = iss.tellg();
|
||||
while (header_end < data.size() && (data[header_end] == ' ' || data[header_end] == '\n')) {
|
||||
header_end++;
|
||||
}
|
||||
|
||||
if (data[1] == '6') {
|
||||
// Binary PPM (P6)
|
||||
size_t pixel_count = width * height;
|
||||
result.pixels.resize(pixel_count * 4);
|
||||
|
||||
for (size_t i = 0; i < pixel_count && header_end + i * 3 + 2 < data.size(); ++i) {
|
||||
result.pixels[i * 4 + 0] = data[header_end + i * 3 + 0]; // R
|
||||
result.pixels[i * 4 + 1] = data[header_end + i * 3 + 1]; // G
|
||||
result.pixels[i * 4 + 2] = data[header_end + i * 3 + 2]; // B
|
||||
result.pixels[i * 4 + 3] = 255; // A
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
namespace tut {
|
||||
|
||||
// ==================== ImageRenderer ====================
|
||||
|
||||
ImageRenderer::ImageRenderer() = default;
|
||||
|
||||
AsciiImage ImageRenderer::render(const ImageData& data, int max_width, int max_height) {
|
||||
AsciiImage result;
|
||||
|
||||
if (!data.is_valid()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 计算缩放比例,保持宽高比
|
||||
// 终端字符通常是2:1的高宽比,所以height需要除以2
|
||||
float aspect = static_cast<float>(data.width) / data.height;
|
||||
int target_width = max_width;
|
||||
int target_height = static_cast<int>(target_width / aspect / 2.0f);
|
||||
|
||||
if (target_height > max_height) {
|
||||
target_height = max_height;
|
||||
target_width = static_cast<int>(target_height * aspect * 2.0f);
|
||||
}
|
||||
|
||||
target_width = std::max(1, std::min(target_width, max_width));
|
||||
target_height = std::max(1, std::min(target_height, max_height));
|
||||
|
||||
// 缩放图片
|
||||
ImageData scaled = resize(data, target_width, target_height);
|
||||
|
||||
result.width = target_width;
|
||||
result.height = target_height;
|
||||
result.lines.resize(target_height);
|
||||
result.colors.resize(target_height);
|
||||
|
||||
for (int y = 0; y < target_height; ++y) {
|
||||
result.lines[y].reserve(target_width);
|
||||
result.colors[y].resize(target_width);
|
||||
|
||||
for (int x = 0; x < target_width; ++x) {
|
||||
int idx = (y * target_width + x) * scaled.channels;
|
||||
|
||||
uint8_t r = scaled.pixels[idx];
|
||||
uint8_t g = scaled.pixels[idx + 1];
|
||||
uint8_t b = scaled.pixels[idx + 2];
|
||||
uint8_t a = (scaled.channels == 4) ? scaled.pixels[idx + 3] : 255;
|
||||
|
||||
// 如果像素透明,使用空格
|
||||
if (a < 128) {
|
||||
result.lines[y] += ' ';
|
||||
result.colors[y][x] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mode_ == Mode::ASCII) {
|
||||
// ASCII模式:使用亮度映射字符
|
||||
int brightness = pixel_brightness(r, g, b);
|
||||
result.lines[y] += brightness_to_char(brightness);
|
||||
} else if (mode_ == Mode::BLOCKS) {
|
||||
// 块模式:使用全块字符,颜色表示像素
|
||||
result.lines[y] += "\u2588"; // █ 全块
|
||||
} else {
|
||||
// 默认使用块
|
||||
result.lines[y] += "\u2588";
|
||||
}
|
||||
|
||||
if (color_enabled_) {
|
||||
result.colors[y][x] = rgb_to_color(r, g, b);
|
||||
} else {
|
||||
int brightness = pixel_brightness(r, g, b);
|
||||
result.colors[y][x] = rgb_to_color(brightness, brightness, brightness);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
ImageData ImageRenderer::load_from_file(const std::string& path) {
|
||||
ImageData data;
|
||||
|
||||
#if HAS_STB_IMAGE
|
||||
int width, height, channels;
|
||||
unsigned char* pixels = stbi_load(path.c_str(), &width, &height, &channels, 4);
|
||||
|
||||
if (pixels) {
|
||||
data.width = width;
|
||||
data.height = height;
|
||||
data.channels = 4;
|
||||
data.pixels.assign(pixels, pixels + width * height * 4);
|
||||
stbi_image_free(pixels);
|
||||
}
|
||||
#else
|
||||
(void)path; // 未使用参数
|
||||
#endif
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
ImageData ImageRenderer::load_from_memory(const std::vector<uint8_t>& buffer) {
|
||||
ImageData data;
|
||||
|
||||
#if HAS_STB_IMAGE
|
||||
int width, height, channels;
|
||||
unsigned char* pixels = stbi_load_from_memory(
|
||||
buffer.data(),
|
||||
static_cast<int>(buffer.size()),
|
||||
&width, &height, &channels, 4
|
||||
);
|
||||
|
||||
if (pixels) {
|
||||
data.width = width;
|
||||
data.height = height;
|
||||
data.channels = 4;
|
||||
data.pixels.assign(pixels, pixels + width * height * 4);
|
||||
stbi_image_free(pixels);
|
||||
}
|
||||
#else
|
||||
// 尝试PPM格式解码
|
||||
data = decode_ppm(buffer);
|
||||
#endif
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
char ImageRenderer::brightness_to_char(int brightness) const {
|
||||
// brightness: 0-255 -> 字符索引
|
||||
int len = 10; // strlen(ASCII_CHARS)
|
||||
int idx = (brightness * (len - 1)) / 255;
|
||||
return ASCII_CHARS[idx];
|
||||
}
|
||||
|
||||
uint32_t ImageRenderer::rgb_to_color(uint8_t r, uint8_t g, uint8_t b) {
|
||||
return (static_cast<uint32_t>(r) << 16) |
|
||||
(static_cast<uint32_t>(g) << 8) |
|
||||
static_cast<uint32_t>(b);
|
||||
}
|
||||
|
||||
int ImageRenderer::pixel_brightness(uint8_t r, uint8_t g, uint8_t b) {
|
||||
// 使用加权平均计算亮度 (ITU-R BT.601)
|
||||
return static_cast<int>(0.299f * r + 0.587f * g + 0.114f * b);
|
||||
}
|
||||
|
||||
ImageData ImageRenderer::resize(const ImageData& src, int new_width, int new_height) {
|
||||
ImageData dst;
|
||||
dst.width = new_width;
|
||||
dst.height = new_height;
|
||||
dst.channels = src.channels;
|
||||
dst.pixels.resize(new_width * new_height * src.channels);
|
||||
|
||||
float x_ratio = static_cast<float>(src.width) / new_width;
|
||||
float y_ratio = static_cast<float>(src.height) / new_height;
|
||||
|
||||
for (int y = 0; y < new_height; ++y) {
|
||||
for (int x = 0; x < new_width; ++x) {
|
||||
// 双线性插值(简化版:最近邻)
|
||||
int src_x = static_cast<int>(x * x_ratio);
|
||||
int src_y = static_cast<int>(y * y_ratio);
|
||||
|
||||
src_x = std::min(src_x, src.width - 1);
|
||||
src_y = std::min(src_y, src.height - 1);
|
||||
|
||||
int src_idx = (src_y * src.width + src_x) * src.channels;
|
||||
int dst_idx = (y * new_width + x) * dst.channels;
|
||||
|
||||
for (int c = 0; c < src.channels; ++c) {
|
||||
dst.pixels[dst_idx + c] = src.pixels[src_idx + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dst;
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
std::string make_image_placeholder(const std::string& alt_text, const std::string& src) {
|
||||
std::string result = "[";
|
||||
|
||||
if (!alt_text.empty()) {
|
||||
result += alt_text;
|
||||
} else if (!src.empty()) {
|
||||
// 从URL提取文件名
|
||||
size_t last_slash = src.rfind('/');
|
||||
if (last_slash != std::string::npos && last_slash + 1 < src.length()) {
|
||||
std::string filename = src.substr(last_slash + 1);
|
||||
// 去掉查询参数
|
||||
size_t query = filename.find('?');
|
||||
if (query != std::string::npos) {
|
||||
filename = filename.substr(0, query);
|
||||
}
|
||||
result += "Image: " + filename;
|
||||
} else {
|
||||
result += "Image";
|
||||
}
|
||||
} else {
|
||||
result += "Image";
|
||||
}
|
||||
|
||||
result += "]";
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* ImageData - 解码后的图片数据
|
||||
*/
|
||||
struct ImageData {
|
||||
std::vector<uint8_t> pixels; // RGBA像素数据
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int channels = 0; // 通道数 (3=RGB, 4=RGBA)
|
||||
|
||||
bool is_valid() const { return width > 0 && height > 0 && !pixels.empty(); }
|
||||
};
|
||||
|
||||
/**
|
||||
* AsciiImage - ASCII艺术渲染结果
|
||||
*/
|
||||
struct AsciiImage {
|
||||
std::vector<std::string> lines; // 每行的ASCII字符
|
||||
std::vector<std::vector<uint32_t>> colors; // 每个字符的颜色 (True Color)
|
||||
int width = 0; // 字符宽度
|
||||
int height = 0; // 字符高度
|
||||
};
|
||||
|
||||
/**
|
||||
* ImageRenderer - 图片渲染器
|
||||
*
|
||||
* 将图片转换为ASCII艺术或彩色块字符
|
||||
*/
|
||||
class ImageRenderer {
|
||||
public:
|
||||
/**
|
||||
* 渲染模式
|
||||
*/
|
||||
enum class Mode {
|
||||
ASCII, // 使用ASCII字符 (@#%*+=-:. )
|
||||
BLOCKS, // 使用Unicode块字符 (▀▄█)
|
||||
BRAILLE // 使用盲文点阵字符
|
||||
};
|
||||
|
||||
ImageRenderer();
|
||||
|
||||
/**
|
||||
* 从原始RGBA数据创建ASCII图像
|
||||
* @param data 图片数据
|
||||
* @param max_width 最大字符宽度
|
||||
* @param max_height 最大字符高度
|
||||
* @return ASCII渲染结果
|
||||
*/
|
||||
AsciiImage render(const ImageData& data, int max_width, int max_height);
|
||||
|
||||
/**
|
||||
* 从文件加载图片 (需要stb_image)
|
||||
* @param path 文件路径
|
||||
* @return 图片数据
|
||||
*/
|
||||
static ImageData load_from_file(const std::string& path);
|
||||
|
||||
/**
|
||||
* 从内存加载图片 (需要stb_image)
|
||||
* @param data 图片二进制数据
|
||||
* @return 图片数据
|
||||
*/
|
||||
static ImageData load_from_memory(const std::vector<uint8_t>& data);
|
||||
|
||||
/**
|
||||
* 设置渲染模式
|
||||
*/
|
||||
void set_mode(Mode mode) { mode_ = mode; }
|
||||
|
||||
/**
|
||||
* 是否启用颜色
|
||||
*/
|
||||
void set_color_enabled(bool enabled) { color_enabled_ = enabled; }
|
||||
|
||||
private:
|
||||
Mode mode_ = Mode::BLOCKS;
|
||||
bool color_enabled_ = true;
|
||||
|
||||
// ASCII字符集 (按亮度从暗到亮)
|
||||
static constexpr const char* ASCII_CHARS = " .:-=+*#%@";
|
||||
|
||||
// 将像素亮度映射到字符
|
||||
char brightness_to_char(int brightness) const;
|
||||
|
||||
// 将RGB转换为True Color值
|
||||
static uint32_t rgb_to_color(uint8_t r, uint8_t g, uint8_t b);
|
||||
|
||||
// 计算像素亮度
|
||||
static int pixel_brightness(uint8_t r, uint8_t g, uint8_t b);
|
||||
|
||||
// 缩放图片
|
||||
static ImageData resize(const ImageData& src, int new_width, int new_height);
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成图片占位符文本
|
||||
* @param alt_text 替代文本
|
||||
* @param src 图片URL (用于显示文件名)
|
||||
* @return 占位符字符串
|
||||
*/
|
||||
std::string make_image_placeholder(const std::string& alt_text, const std::string& src = "");
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,933 +0,0 @@
|
|||
#include "layout.h"
|
||||
#include "decorations.h"
|
||||
#include "image.h"
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
|
||||
namespace tut {
|
||||
|
||||
// ==================== LayoutEngine ====================
|
||||
|
||||
LayoutEngine::LayoutEngine(int viewport_width)
|
||||
: viewport_width_(viewport_width)
|
||||
, content_width_(viewport_width - MARGIN_LEFT - MARGIN_RIGHT)
|
||||
{
|
||||
}
|
||||
|
||||
LayoutResult LayoutEngine::layout(const DocumentTree& doc) {
|
||||
LayoutResult result;
|
||||
result.title = doc.title;
|
||||
result.url = doc.url;
|
||||
|
||||
if (!doc.root) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Context ctx;
|
||||
layout_node(doc.root.get(), ctx, result.blocks);
|
||||
|
||||
// 计算总行数并收集链接和字段位置
|
||||
int total = 0;
|
||||
|
||||
// 预分配位置数组
|
||||
size_t num_links = doc.links.size();
|
||||
size_t num_fields = doc.form_fields.size();
|
||||
result.link_positions.resize(num_links, {-1, -1});
|
||||
result.field_lines.resize(num_fields, -1);
|
||||
|
||||
for (const auto& block : result.blocks) {
|
||||
total += block.margin_top;
|
||||
|
||||
for (const auto& line : block.lines) {
|
||||
for (const auto& span : line.spans) {
|
||||
// 记录链接位置
|
||||
if (span.link_index >= 0 && span.link_index < static_cast<int>(num_links)) {
|
||||
auto& pos = result.link_positions[span.link_index];
|
||||
if (pos.start_line < 0) {
|
||||
pos.start_line = total;
|
||||
}
|
||||
pos.end_line = total;
|
||||
}
|
||||
// 记录字段位置
|
||||
if (span.field_index >= 0 && span.field_index < static_cast<int>(num_fields)) {
|
||||
if (result.field_lines[span.field_index] < 0) {
|
||||
result.field_lines[span.field_index] = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
total++;
|
||||
}
|
||||
|
||||
total += block.margin_bottom;
|
||||
}
|
||||
result.total_lines = total;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks) {
|
||||
if (!node || !node->should_render()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node->node_type == NodeType::DOCUMENT) {
|
||||
for (const auto& child : node->children) {
|
||||
layout_node(child.get(), ctx, blocks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理表单内联元素
|
||||
if (node->element_type == ElementType::INPUT ||
|
||||
node->element_type == ElementType::BUTTON ||
|
||||
node->element_type == ElementType::TEXTAREA ||
|
||||
node->element_type == ElementType::SELECT) {
|
||||
layout_form_element(node, ctx, blocks);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理图片元素
|
||||
if (node->element_type == ElementType::IMAGE) {
|
||||
layout_image_element(node, ctx, blocks);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理块级元素
|
||||
if (node->is_block_element()) {
|
||||
layout_block_element(node, ctx, blocks);
|
||||
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) {
|
||||
LayoutBlock block;
|
||||
block.type = node->element_type;
|
||||
|
||||
// 设置边距
|
||||
switch (node->element_type) {
|
||||
case ElementType::HEADING1:
|
||||
block.margin_top = 1;
|
||||
block.margin_bottom = 1;
|
||||
break;
|
||||
case ElementType::HEADING2:
|
||||
case ElementType::HEADING3:
|
||||
block.margin_top = 1;
|
||||
block.margin_bottom = 0;
|
||||
break;
|
||||
case ElementType::PARAGRAPH:
|
||||
block.margin_top = 0;
|
||||
block.margin_bottom = 1;
|
||||
break;
|
||||
case ElementType::LIST_ITEM:
|
||||
case ElementType::ORDERED_LIST_ITEM:
|
||||
block.margin_top = 0;
|
||||
block.margin_bottom = 0;
|
||||
break;
|
||||
case ElementType::BLOCKQUOTE:
|
||||
block.margin_top = 1;
|
||||
block.margin_bottom = 1;
|
||||
break;
|
||||
case ElementType::CODE_BLOCK:
|
||||
block.margin_top = 1;
|
||||
block.margin_bottom = 1;
|
||||
break;
|
||||
case ElementType::HORIZONTAL_RULE:
|
||||
block.margin_top = 1;
|
||||
block.margin_bottom = 1;
|
||||
break;
|
||||
default:
|
||||
block.margin_top = 0;
|
||||
block.margin_bottom = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// 处理特殊块元素
|
||||
if (node->element_type == ElementType::HORIZONTAL_RULE) {
|
||||
// 水平线
|
||||
LayoutLine line;
|
||||
StyledSpan hr_span;
|
||||
hr_span.text = make_horizontal_line(content_width_, chars::SGL_HORIZONTAL);
|
||||
hr_span.fg = colors::DIVIDER;
|
||||
line.spans.push_back(hr_span);
|
||||
line.indent = MARGIN_LEFT;
|
||||
block.lines.push_back(line);
|
||||
blocks.push_back(block);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是列表容器(通过tag_name判断)
|
||||
if (node->tag_name == "ul" || node->tag_name == "ol") {
|
||||
// 列表:递归处理子元素
|
||||
ctx.list_depth++;
|
||||
bool is_ordered = (node->tag_name == "ol");
|
||||
if (is_ordered) {
|
||||
ctx.ordered_list_counter = 1;
|
||||
}
|
||||
|
||||
for (const auto& child : node->children) {
|
||||
if (child->element_type == ElementType::LIST_ITEM ||
|
||||
child->element_type == ElementType::ORDERED_LIST_ITEM) {
|
||||
layout_block_element(child.get(), ctx, blocks);
|
||||
if (is_ordered) {
|
||||
ctx.ordered_list_counter++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.list_depth--;
|
||||
return;
|
||||
}
|
||||
|
||||
if (node->element_type == ElementType::BLOCKQUOTE) {
|
||||
ctx.in_blockquote = true;
|
||||
}
|
||||
|
||||
if (node->element_type == ElementType::CODE_BLOCK) {
|
||||
ctx.in_pre = true;
|
||||
}
|
||||
|
||||
// 收集内联内容
|
||||
std::vector<StyledSpan> spans;
|
||||
|
||||
// 列表项的标记
|
||||
if (node->element_type == ElementType::LIST_ITEM) {
|
||||
StyledSpan marker;
|
||||
marker.text = get_list_marker(ctx.list_depth, ctx.ordered_list_counter > 0, ctx.ordered_list_counter);
|
||||
marker.fg = colors::FG_SECONDARY;
|
||||
spans.push_back(marker);
|
||||
}
|
||||
|
||||
collect_inline_content(node, ctx, spans);
|
||||
|
||||
if (node->element_type == ElementType::BLOCKQUOTE) {
|
||||
ctx.in_blockquote = false;
|
||||
}
|
||||
|
||||
if (node->element_type == ElementType::CODE_BLOCK) {
|
||||
ctx.in_pre = false;
|
||||
}
|
||||
|
||||
// 计算缩进
|
||||
int indent = MARGIN_LEFT;
|
||||
if (ctx.list_depth > 0) {
|
||||
indent += ctx.list_depth * 2;
|
||||
}
|
||||
if (ctx.in_blockquote) {
|
||||
indent += 2;
|
||||
}
|
||||
|
||||
// 换行
|
||||
int available_width = content_width_ - (indent - MARGIN_LEFT);
|
||||
if (ctx.in_pre) {
|
||||
// 预格式化文本不换行
|
||||
for (const auto& span : spans) {
|
||||
LayoutLine line;
|
||||
line.indent = indent;
|
||||
line.spans.push_back(span);
|
||||
block.lines.push_back(line);
|
||||
}
|
||||
} else {
|
||||
block.lines = wrap_text(spans, available_width, indent);
|
||||
}
|
||||
|
||||
// 引用块添加边框
|
||||
if (node->element_type == ElementType::BLOCKQUOTE && !block.lines.empty()) {
|
||||
for (auto& line : block.lines) {
|
||||
StyledSpan border;
|
||||
border.text = chars::QUOTE_LEFT + std::string(" ");
|
||||
border.fg = colors::QUOTE_BORDER;
|
||||
line.spans.insert(line.spans.begin(), border);
|
||||
}
|
||||
}
|
||||
|
||||
if (!block.lines.empty()) {
|
||||
blocks.push_back(block);
|
||||
}
|
||||
|
||||
// 处理子块元素
|
||||
for (const auto& child : node->children) {
|
||||
if (child->is_block_element()) {
|
||||
layout_node(child.get(), ctx, blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void LayoutEngine::layout_form_element(const DomNode* node, Context& /*ctx*/, std::vector<LayoutBlock>& blocks) {
|
||||
LayoutBlock block;
|
||||
block.type = node->element_type;
|
||||
block.margin_top = 0;
|
||||
block.margin_bottom = 0;
|
||||
|
||||
LayoutLine line;
|
||||
line.indent = MARGIN_LEFT;
|
||||
|
||||
if (node->element_type == ElementType::INPUT) {
|
||||
// 渲染输入框
|
||||
std::string input_type = node->input_type;
|
||||
|
||||
if (input_type == "submit" || input_type == "button") {
|
||||
// 按钮样式: [ Submit ]
|
||||
std::string label = node->value.empty() ? "Submit" : node->value;
|
||||
StyledSpan span;
|
||||
span.text = "[ " + label + " ]";
|
||||
span.fg = colors::INPUT_FOCUS;
|
||||
span.bg = colors::INPUT_BG;
|
||||
span.attrs = ATTR_BOLD;
|
||||
span.field_index = node->field_index;
|
||||
line.spans.push_back(span);
|
||||
} else if (input_type == "checkbox") {
|
||||
// 复选框: [x] 或 [ ]
|
||||
StyledSpan span;
|
||||
span.text = node->checked ? "[x]" : "[ ]";
|
||||
span.fg = colors::INPUT_FOCUS;
|
||||
span.field_index = node->field_index;
|
||||
line.spans.push_back(span);
|
||||
|
||||
// 添加标签(如果有name)
|
||||
if (!node->name.empty()) {
|
||||
StyledSpan label;
|
||||
label.text = " " + node->name;
|
||||
label.fg = colors::FG_PRIMARY;
|
||||
line.spans.push_back(label);
|
||||
}
|
||||
} else if (input_type == "radio") {
|
||||
// 单选框: (o) 或 ( )
|
||||
StyledSpan span;
|
||||
span.text = node->checked ? "(o)" : "( )";
|
||||
span.fg = colors::INPUT_FOCUS;
|
||||
span.field_index = node->field_index;
|
||||
line.spans.push_back(span);
|
||||
|
||||
if (!node->name.empty()) {
|
||||
StyledSpan label;
|
||||
label.text = " " + node->name;
|
||||
label.fg = colors::FG_PRIMARY;
|
||||
line.spans.push_back(label);
|
||||
}
|
||||
} else {
|
||||
// 文本输入框: [placeholder____]
|
||||
std::string display_text;
|
||||
if (!node->value.empty()) {
|
||||
display_text = node->value;
|
||||
} else if (!node->placeholder.empty()) {
|
||||
display_text = node->placeholder;
|
||||
} else {
|
||||
display_text = "";
|
||||
}
|
||||
|
||||
// 限制显示宽度
|
||||
int field_width = 20;
|
||||
if (display_text.length() > static_cast<size_t>(field_width)) {
|
||||
display_text = display_text.substr(0, field_width - 1) + "…";
|
||||
} else {
|
||||
display_text += std::string(field_width - display_text.length(), '_');
|
||||
}
|
||||
|
||||
StyledSpan span;
|
||||
span.text = "[" + display_text + "]";
|
||||
span.fg = node->value.empty() ? colors::FG_DIM : colors::FG_PRIMARY;
|
||||
span.bg = colors::INPUT_BG;
|
||||
span.field_index = node->field_index;
|
||||
line.spans.push_back(span);
|
||||
}
|
||||
} else if (node->element_type == ElementType::BUTTON) {
|
||||
// 按钮
|
||||
std::string label = node->get_all_text();
|
||||
if (label.empty()) {
|
||||
label = node->value.empty() ? "Button" : node->value;
|
||||
}
|
||||
StyledSpan span;
|
||||
span.text = "[ " + label + " ]";
|
||||
span.fg = colors::INPUT_FOCUS;
|
||||
span.bg = colors::INPUT_BG;
|
||||
span.attrs = ATTR_BOLD;
|
||||
span.field_index = node->field_index;
|
||||
line.spans.push_back(span);
|
||||
} else if (node->element_type == ElementType::TEXTAREA) {
|
||||
// 文本区域
|
||||
std::string content = node->value.empty() ? node->placeholder : node->value;
|
||||
if (content.empty()) {
|
||||
content = "(empty)";
|
||||
}
|
||||
|
||||
StyledSpan span;
|
||||
span.text = "[" + content + "]";
|
||||
span.fg = colors::FG_PRIMARY;
|
||||
span.bg = colors::INPUT_BG;
|
||||
span.field_index = node->field_index;
|
||||
line.spans.push_back(span);
|
||||
} else if (node->element_type == ElementType::SELECT) {
|
||||
// 下拉选择 - 显示当前选中的选项
|
||||
StyledSpan span;
|
||||
std::string selected_text = "Select";
|
||||
if (node->selected_option >= 0 && node->selected_option < static_cast<int>(node->options.size())) {
|
||||
selected_text = node->options[node->selected_option].second;
|
||||
}
|
||||
span.text = "[▼ " + selected_text + "]";
|
||||
span.fg = colors::INPUT_FOCUS;
|
||||
span.bg = colors::INPUT_BG;
|
||||
span.field_index = node->field_index;
|
||||
line.spans.push_back(span);
|
||||
}
|
||||
|
||||
if (!line.spans.empty()) {
|
||||
block.lines.push_back(line);
|
||||
blocks.push_back(block);
|
||||
}
|
||||
}
|
||||
|
||||
void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, std::vector<LayoutBlock>& blocks) {
|
||||
LayoutBlock block;
|
||||
block.type = ElementType::IMAGE;
|
||||
block.margin_top = 0;
|
||||
block.margin_bottom = 1;
|
||||
|
||||
// 检查是否有解码后的图片数据
|
||||
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;
|
||||
span.text = placeholder;
|
||||
span.fg = colors::FG_DIM; // 使用较暗的颜色表示占位符
|
||||
span.attrs = ATTR_NONE;
|
||||
|
||||
line.spans.push_back(span);
|
||||
block.lines.push_back(line);
|
||||
blocks.push_back(block);
|
||||
}
|
||||
|
||||
// 辅助函数:检查是否需要在两个文本之间添加空格
|
||||
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;
|
||||
|
||||
if (node->node_type == NodeType::TEXT) {
|
||||
layout_text(node, ctx, spans);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node->is_inline_element() || node->node_type == NodeType::ELEMENT) {
|
||||
// 设置样式
|
||||
uint32_t fg = get_element_fg_color(node->element_type);
|
||||
uint8_t attrs = get_element_attrs(node->element_type);
|
||||
|
||||
// 处理链接
|
||||
int link_idx = node->link_index;
|
||||
|
||||
// 递归处理子节点
|
||||
for (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 = text;
|
||||
span.fg = fg;
|
||||
span.attrs = attrs;
|
||||
span.link_index = link_idx;
|
||||
|
||||
if (ctx.in_blockquote) {
|
||||
span.fg = colors::QUOTE_FG;
|
||||
}
|
||||
|
||||
spans.push_back(span);
|
||||
} else if (!child->is_block_element()) {
|
||||
// 获取子节点的全部文本,用于检查是否需要空格
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = text;
|
||||
span.fg = colors::FG_PRIMARY;
|
||||
|
||||
if (ctx.in_blockquote) {
|
||||
span.fg = colors::QUOTE_FG;
|
||||
}
|
||||
|
||||
spans.push_back(span);
|
||||
}
|
||||
|
||||
std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& spans, int available_width, int indent) {
|
||||
std::vector<LayoutLine> lines;
|
||||
|
||||
if (spans.empty()) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
LayoutLine current_line;
|
||||
current_line.indent = indent;
|
||||
size_t current_width = 0;
|
||||
bool is_line_start = true; // 整行的开始标记
|
||||
|
||||
for (const auto& span : spans) {
|
||||
// 分词处理
|
||||
std::istringstream iss(span.text);
|
||||
std::string word;
|
||||
bool first_word_in_span = true;
|
||||
|
||||
while (iss >> word) {
|
||||
size_t word_width = Unicode::display_width(word);
|
||||
|
||||
// 检查是否需要换行
|
||||
if (current_width > 0 && current_width + 1 + word_width > static_cast<size_t>(available_width)) {
|
||||
// 当前行已满,开始新行
|
||||
if (!current_line.spans.empty()) {
|
||||
lines.push_back(current_line);
|
||||
}
|
||||
current_line = LayoutLine();
|
||||
current_line.indent = indent;
|
||||
current_width = 0;
|
||||
is_line_start = true;
|
||||
}
|
||||
|
||||
// 添加空格(如果不是行首且不是第一个单词)
|
||||
// 需要在不同 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加单词
|
||||
StyledSpan word_span = span;
|
||||
word_span.text = word;
|
||||
current_line.spans.push_back(word_span);
|
||||
current_width += word_width;
|
||||
is_line_start = false;
|
||||
first_word_in_span = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一行
|
||||
if (!current_line.spans.empty()) {
|
||||
lines.push_back(current_line);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
uint32_t LayoutEngine::get_element_fg_color(ElementType type) const {
|
||||
switch (type) {
|
||||
case ElementType::HEADING1:
|
||||
return colors::H1_FG;
|
||||
case ElementType::HEADING2:
|
||||
return colors::H2_FG;
|
||||
case ElementType::HEADING3:
|
||||
case ElementType::HEADING4:
|
||||
case ElementType::HEADING5:
|
||||
case ElementType::HEADING6:
|
||||
return colors::H3_FG;
|
||||
case ElementType::LINK:
|
||||
return colors::LINK_FG;
|
||||
case ElementType::CODE_BLOCK:
|
||||
return colors::CODE_FG;
|
||||
case ElementType::BLOCKQUOTE:
|
||||
return colors::QUOTE_FG;
|
||||
default:
|
||||
return colors::FG_PRIMARY;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t LayoutEngine::get_element_attrs(ElementType type) const {
|
||||
switch (type) {
|
||||
case ElementType::HEADING1:
|
||||
case ElementType::HEADING2:
|
||||
case ElementType::HEADING3:
|
||||
case ElementType::HEADING4:
|
||||
case ElementType::HEADING5:
|
||||
case ElementType::HEADING6:
|
||||
return ATTR_BOLD;
|
||||
case ElementType::LINK:
|
||||
return ATTR_UNDERLINE;
|
||||
default:
|
||||
return ATTR_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
std::string LayoutEngine::get_list_marker(int depth, bool ordered, int counter) const {
|
||||
if (ordered) {
|
||||
return std::to_string(counter) + ". ";
|
||||
}
|
||||
|
||||
// 不同层级使用不同的标记
|
||||
switch ((depth - 1) % 3) {
|
||||
case 0: return std::string(chars::BULLET) + " ";
|
||||
case 1: return std::string(chars::BULLET_HOLLOW) + " ";
|
||||
case 2: return std::string(chars::BULLET_SQUARE) + " ";
|
||||
default: return std::string(chars::BULLET) + " ";
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DocumentRenderer ====================
|
||||
|
||||
DocumentRenderer::DocumentRenderer(FrameBuffer& buffer)
|
||||
: buffer_(buffer)
|
||||
{
|
||||
}
|
||||
|
||||
void DocumentRenderer::render(const LayoutResult& layout, int scroll_offset, const RenderContext& ctx) {
|
||||
int buffer_height = buffer_.height();
|
||||
int y = 0; // 缓冲区行位置
|
||||
int doc_line = 0; // 文档行位置
|
||||
|
||||
for (const auto& block : layout.blocks) {
|
||||
// 处理上边距
|
||||
for (int i = 0; i < block.margin_top; ++i) {
|
||||
if (doc_line >= scroll_offset && y < buffer_height) {
|
||||
// 空行
|
||||
y++;
|
||||
}
|
||||
doc_line++;
|
||||
}
|
||||
|
||||
// 渲染内容行
|
||||
for (const auto& line : block.lines) {
|
||||
if (doc_line >= scroll_offset) {
|
||||
if (y >= buffer_height) {
|
||||
return; // 超出视口
|
||||
}
|
||||
render_line(line, y, doc_line, ctx);
|
||||
y++;
|
||||
}
|
||||
doc_line++;
|
||||
}
|
||||
|
||||
// 处理下边距
|
||||
for (int i = 0; i < block.margin_bottom; ++i) {
|
||||
if (doc_line >= scroll_offset && y < buffer_height) {
|
||||
// 空行
|
||||
y++;
|
||||
}
|
||||
doc_line++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int DocumentRenderer::find_match_at(const SearchContext* search, int doc_line, int col) const {
|
||||
if (!search || !search->enabled || search->matches.empty()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < search->matches.size(); ++i) {
|
||||
const auto& m = search->matches[i];
|
||||
if (m.line == doc_line && col >= m.start_col && col < m.start_col + m.length) {
|
||||
return static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void DocumentRenderer::render_line(const LayoutLine& line, int y, int doc_line, const RenderContext& ctx) {
|
||||
int x = line.indent;
|
||||
|
||||
for (const auto& span : line.spans) {
|
||||
// 检查是否需要搜索高亮
|
||||
bool has_search_match = (ctx.search && ctx.search->enabled && !ctx.search->matches.empty());
|
||||
|
||||
if (has_search_match) {
|
||||
// 按字符渲染以支持部分高亮
|
||||
const std::string& text = span.text;
|
||||
int char_col = x;
|
||||
|
||||
for (size_t i = 0; i < text.size(); ) {
|
||||
// 获取字符宽度(处理UTF-8)
|
||||
int char_bytes = 1;
|
||||
unsigned char c = text[i];
|
||||
if ((c & 0x80) == 0) {
|
||||
char_bytes = 1;
|
||||
} else if ((c & 0xE0) == 0xC0) {
|
||||
char_bytes = 2;
|
||||
} else if ((c & 0xF0) == 0xE0) {
|
||||
char_bytes = 3;
|
||||
} else if ((c & 0xF8) == 0xF0) {
|
||||
char_bytes = 4;
|
||||
}
|
||||
|
||||
std::string ch = text.substr(i, char_bytes);
|
||||
int char_width = static_cast<int>(Unicode::display_width(ch));
|
||||
|
||||
uint32_t fg = span.fg;
|
||||
uint32_t bg = span.bg;
|
||||
uint8_t attrs = span.attrs;
|
||||
|
||||
// 检查搜索匹配
|
||||
int match_idx = find_match_at(ctx.search, doc_line, char_col);
|
||||
if (match_idx >= 0) {
|
||||
// 搜索高亮
|
||||
if (match_idx == ctx.search->current_match_idx) {
|
||||
fg = colors::SEARCH_CURRENT_FG;
|
||||
bg = colors::SEARCH_CURRENT_BG;
|
||||
} else {
|
||||
fg = colors::SEARCH_MATCH_FG;
|
||||
bg = colors::SEARCH_MATCH_BG;
|
||||
}
|
||||
attrs |= ATTR_BOLD;
|
||||
} else if (span.link_index >= 0 && span.link_index == ctx.active_link) {
|
||||
// 活跃链接高亮
|
||||
fg = colors::LINK_ACTIVE;
|
||||
attrs |= ATTR_BOLD;
|
||||
} else if (span.field_index >= 0 && span.field_index == ctx.active_field) {
|
||||
// 活跃表单字段高亮
|
||||
fg = colors::SEARCH_CURRENT_FG;
|
||||
bg = colors::INPUT_FOCUS;
|
||||
attrs |= ATTR_BOLD;
|
||||
}
|
||||
|
||||
buffer_.set_text(char_col, y, ch, fg, bg, attrs);
|
||||
char_col += char_width;
|
||||
i += char_bytes;
|
||||
}
|
||||
x = char_col;
|
||||
} else {
|
||||
// 无搜索匹配时,整体渲染(更高效)
|
||||
uint32_t fg = span.fg;
|
||||
uint32_t bg = span.bg;
|
||||
uint8_t attrs = span.attrs;
|
||||
|
||||
// 高亮活跃链接
|
||||
if (span.link_index >= 0 && span.link_index == ctx.active_link) {
|
||||
fg = colors::LINK_ACTIVE;
|
||||
attrs |= ATTR_BOLD;
|
||||
}
|
||||
// 高亮活跃表单字段
|
||||
else if (span.field_index >= 0 && span.field_index == ctx.active_field) {
|
||||
fg = colors::SEARCH_CURRENT_FG;
|
||||
bg = colors::INPUT_FOCUS;
|
||||
attrs |= ATTR_BOLD;
|
||||
}
|
||||
|
||||
buffer_.set_text(x, y, span.text, fg, bg, attrs);
|
||||
x += static_cast<int>(span.display_width());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "renderer.h"
|
||||
#include "colors.h"
|
||||
#include "../dom_tree.h"
|
||||
#include "../utils/unicode.h"
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* StyledSpan - 带样式的文本片段
|
||||
*
|
||||
* 表示一段具有统一样式的文本
|
||||
*/
|
||||
struct StyledSpan {
|
||||
std::string text;
|
||||
uint32_t fg = colors::FG_PRIMARY;
|
||||
uint32_t bg = colors::BG_PRIMARY;
|
||||
uint8_t attrs = ATTR_NONE;
|
||||
int link_index = -1; // -1表示非链接
|
||||
int field_index = -1; // -1表示非表单字段
|
||||
|
||||
size_t display_width() const {
|
||||
return Unicode::display_width(text);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* LayoutLine - 布局行
|
||||
*
|
||||
* 表示一行渲染内容,由多个StyledSpan组成
|
||||
*/
|
||||
struct LayoutLine {
|
||||
std::vector<StyledSpan> spans;
|
||||
int indent = 0; // 行首缩进(字符数)
|
||||
bool is_blank = false;
|
||||
|
||||
size_t total_width() const {
|
||||
size_t width = indent;
|
||||
for (const auto& span : spans) {
|
||||
width += span.display_width();
|
||||
}
|
||||
return width;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* LayoutBlock - 布局块
|
||||
*
|
||||
* 表示一个块级元素的布局结果
|
||||
* 如段落、标题、列表项等
|
||||
*/
|
||||
struct LayoutBlock {
|
||||
std::vector<LayoutLine> lines;
|
||||
int margin_top = 0; // 上边距(行数)
|
||||
int margin_bottom = 0; // 下边距(行数)
|
||||
ElementType type = ElementType::PARAGRAPH;
|
||||
};
|
||||
|
||||
/**
|
||||
* LinkPosition - 链接位置信息
|
||||
*/
|
||||
struct LinkPosition {
|
||||
int start_line; // 起始行
|
||||
int end_line; // 结束行(可能跨多行)
|
||||
};
|
||||
|
||||
/**
|
||||
* LayoutResult - 布局结果
|
||||
*
|
||||
* 整个文档的布局结果
|
||||
*/
|
||||
struct LayoutResult {
|
||||
std::vector<LayoutBlock> blocks;
|
||||
int total_lines = 0; // 总行数(包括边距)
|
||||
std::string title;
|
||||
std::string url;
|
||||
|
||||
// 链接位置映射 (link_index -> LinkPosition)
|
||||
std::vector<LinkPosition> link_positions;
|
||||
|
||||
// 表单字段位置映射 (field_index -> line_number)
|
||||
std::vector<int> field_lines;
|
||||
};
|
||||
|
||||
/**
|
||||
* LayoutEngine - 布局引擎
|
||||
*
|
||||
* 将DOM树转换为布局结果
|
||||
*/
|
||||
class LayoutEngine {
|
||||
public:
|
||||
explicit LayoutEngine(int viewport_width);
|
||||
|
||||
/**
|
||||
* 计算文档布局
|
||||
*/
|
||||
LayoutResult layout(const DocumentTree& doc);
|
||||
|
||||
/**
|
||||
* 设置视口宽度
|
||||
*/
|
||||
void set_viewport_width(int width) { viewport_width_ = width; }
|
||||
|
||||
private:
|
||||
int viewport_width_;
|
||||
int content_width_; // 实际内容宽度(视口宽度减去边距)
|
||||
static constexpr int MARGIN_LEFT = 2;
|
||||
static constexpr int MARGIN_RIGHT = 2;
|
||||
|
||||
// 布局上下文
|
||||
struct Context {
|
||||
int list_depth = 0;
|
||||
int ordered_list_counter = 0;
|
||||
bool in_blockquote = false;
|
||||
bool in_pre = false;
|
||||
};
|
||||
|
||||
// 布局处理方法
|
||||
void layout_node(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks);
|
||||
void layout_block_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks);
|
||||
void layout_form_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks);
|
||||
void layout_image_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks);
|
||||
void layout_text(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans);
|
||||
|
||||
// 收集内联内容
|
||||
void collect_inline_content(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans);
|
||||
|
||||
// 文本换行
|
||||
std::vector<LayoutLine> wrap_text(const std::vector<StyledSpan>& spans, int available_width, int indent = 0);
|
||||
|
||||
// 获取元素样式
|
||||
uint32_t get_element_fg_color(ElementType type) const;
|
||||
uint8_t get_element_attrs(ElementType type) const;
|
||||
|
||||
// 获取列表标记
|
||||
std::string get_list_marker(int depth, bool ordered, int counter) const;
|
||||
};
|
||||
|
||||
/**
|
||||
* SearchMatch - 搜索匹配信息
|
||||
*/
|
||||
struct SearchMatch {
|
||||
int line; // 文档行号
|
||||
int start_col; // 行内起始列
|
||||
int length; // 匹配长度
|
||||
};
|
||||
|
||||
/**
|
||||
* SearchContext - 搜索上下文
|
||||
*/
|
||||
struct SearchContext {
|
||||
std::vector<SearchMatch> matches;
|
||||
int current_match_idx = -1; // 当前高亮的匹配索引
|
||||
bool enabled = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* RenderContext - 渲染上下文
|
||||
*/
|
||||
struct RenderContext {
|
||||
int active_link = -1; // 当前活跃链接索引
|
||||
int active_field = -1; // 当前活跃表单字段索引
|
||||
const SearchContext* search = nullptr; // 搜索上下文
|
||||
};
|
||||
|
||||
/**
|
||||
* DocumentRenderer - 文档渲染器
|
||||
*
|
||||
* 将LayoutResult渲染到FrameBuffer
|
||||
*/
|
||||
class DocumentRenderer {
|
||||
public:
|
||||
explicit DocumentRenderer(FrameBuffer& buffer);
|
||||
|
||||
/**
|
||||
* 渲染布局结果到缓冲区
|
||||
*
|
||||
* @param layout 布局结果
|
||||
* @param scroll_offset 滚动偏移(行数)
|
||||
* @param ctx 渲染上下文
|
||||
*/
|
||||
void render(const LayoutResult& layout, int scroll_offset, const RenderContext& ctx = {});
|
||||
|
||||
private:
|
||||
FrameBuffer& buffer_;
|
||||
|
||||
void render_line(const LayoutLine& line, int y, int doc_line, const RenderContext& ctx);
|
||||
|
||||
// 检查位置是否在搜索匹配中
|
||||
int find_match_at(const SearchContext* search, int doc_line, int col) const;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
#include "renderer.h"
|
||||
#include "../utils/unicode.h"
|
||||
|
||||
namespace tut {
|
||||
|
||||
// ============================================================================
|
||||
// FrameBuffer Implementation
|
||||
// ============================================================================
|
||||
|
||||
FrameBuffer::FrameBuffer(int width, int height)
|
||||
: width_(width), height_(height) {
|
||||
empty_cell_.content = " ";
|
||||
resize(width, height);
|
||||
}
|
||||
|
||||
void FrameBuffer::resize(int width, int height) {
|
||||
width_ = width;
|
||||
height_ = height;
|
||||
cells_.resize(height);
|
||||
for (auto& row : cells_) {
|
||||
row.resize(width, empty_cell_);
|
||||
}
|
||||
}
|
||||
|
||||
void FrameBuffer::clear() {
|
||||
for (auto& row : cells_) {
|
||||
std::fill(row.begin(), row.end(), empty_cell_);
|
||||
}
|
||||
}
|
||||
|
||||
void FrameBuffer::clear_with_color(uint32_t bg) {
|
||||
Cell cell = empty_cell_;
|
||||
cell.bg = bg;
|
||||
for (auto& row : cells_) {
|
||||
std::fill(row.begin(), row.end(), cell);
|
||||
}
|
||||
}
|
||||
|
||||
void FrameBuffer::set_cell(int x, int y, const Cell& cell) {
|
||||
if (x >= 0 && x < width_ && y >= 0 && y < height_) {
|
||||
cells_[y][x] = cell;
|
||||
}
|
||||
}
|
||||
|
||||
const Cell& FrameBuffer::get_cell(int x, int y) const {
|
||||
if (x >= 0 && x < width_ && y >= 0 && y < height_) {
|
||||
return cells_[y][x];
|
||||
}
|
||||
return empty_cell_;
|
||||
}
|
||||
|
||||
void FrameBuffer::set_text(int x, int y, const std::string& text,
|
||||
uint32_t fg, uint32_t bg, uint8_t attrs) {
|
||||
if (y < 0 || y >= height_) return;
|
||||
|
||||
size_t i = 0;
|
||||
int cur_x = x;
|
||||
|
||||
while (i < text.length() && cur_x < width_) {
|
||||
if (cur_x < 0) {
|
||||
// Skip characters before visible area
|
||||
i += Unicode::char_byte_length(text, i);
|
||||
cur_x++;
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t byte_len = Unicode::char_byte_length(text, i);
|
||||
std::string ch = text.substr(i, byte_len);
|
||||
|
||||
// Determine character width
|
||||
size_t char_width = 1;
|
||||
unsigned char c = text[i];
|
||||
if ((c & 0xF0) == 0xE0 || (c & 0xF8) == 0xF0) {
|
||||
char_width = 2; // CJK or emoji
|
||||
}
|
||||
|
||||
Cell cell;
|
||||
cell.content = ch;
|
||||
cell.fg = fg;
|
||||
cell.bg = bg;
|
||||
cell.attrs = attrs;
|
||||
|
||||
set_cell(cur_x, y, cell);
|
||||
|
||||
// For wide characters, mark next cell as placeholder
|
||||
if (char_width == 2 && cur_x + 1 < width_) {
|
||||
Cell placeholder;
|
||||
placeholder.content = ""; // Empty = continuation of previous cell
|
||||
placeholder.fg = fg;
|
||||
placeholder.bg = bg;
|
||||
placeholder.attrs = attrs;
|
||||
set_cell(cur_x + 1, y, placeholder);
|
||||
}
|
||||
|
||||
cur_x += char_width;
|
||||
i += byte_len;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Renderer Implementation
|
||||
// ============================================================================
|
||||
|
||||
Renderer::Renderer(Terminal& terminal)
|
||||
: terminal_(terminal), prev_buffer_(1, 1) {
|
||||
int w, h;
|
||||
terminal_.get_size(w, h);
|
||||
prev_buffer_.resize(w, h);
|
||||
}
|
||||
|
||||
void Renderer::render(const FrameBuffer& buffer) {
|
||||
int w = buffer.width();
|
||||
int h = buffer.height();
|
||||
|
||||
// Check if resize needed
|
||||
if (prev_buffer_.width() != w || prev_buffer_.height() != h) {
|
||||
prev_buffer_.resize(w, h);
|
||||
need_full_redraw_ = true;
|
||||
}
|
||||
|
||||
terminal_.hide_cursor();
|
||||
|
||||
uint32_t last_fg = 0xFFFFFFFF; // Invalid color to force first set
|
||||
uint32_t last_bg = 0xFFFFFFFF;
|
||||
uint8_t last_attrs = 0xFF;
|
||||
int last_x = -2;
|
||||
|
||||
// 批量输出缓冲
|
||||
std::string batch_text;
|
||||
int batch_start_x = 0;
|
||||
int batch_y = 0;
|
||||
uint32_t batch_fg = 0;
|
||||
uint32_t batch_bg = 0;
|
||||
uint8_t batch_attrs = 0;
|
||||
|
||||
auto flush_batch = [&]() {
|
||||
if (batch_text.empty()) return;
|
||||
|
||||
terminal_.move_cursor(batch_start_x, batch_y);
|
||||
|
||||
if (batch_fg != last_fg) {
|
||||
terminal_.set_foreground(batch_fg);
|
||||
last_fg = batch_fg;
|
||||
}
|
||||
if (batch_bg != last_bg) {
|
||||
terminal_.set_background(batch_bg);
|
||||
last_bg = batch_bg;
|
||||
}
|
||||
if (batch_attrs != last_attrs) {
|
||||
terminal_.reset_attributes();
|
||||
if (batch_attrs & ATTR_BOLD) terminal_.set_bold(true);
|
||||
if (batch_attrs & ATTR_ITALIC) terminal_.set_italic(true);
|
||||
if (batch_attrs & ATTR_UNDERLINE) terminal_.set_underline(true);
|
||||
if (batch_attrs & ATTR_REVERSE) terminal_.set_reverse(true);
|
||||
if (batch_attrs & ATTR_DIM) terminal_.set_dim(true);
|
||||
last_attrs = batch_attrs;
|
||||
terminal_.set_foreground(batch_fg);
|
||||
terminal_.set_background(batch_bg);
|
||||
}
|
||||
|
||||
terminal_.print(batch_text);
|
||||
batch_text.clear();
|
||||
};
|
||||
|
||||
for (int y = 0; y < h; y++) {
|
||||
for (int x = 0; x < w; x++) {
|
||||
const Cell& cell = buffer.get_cell(x, y);
|
||||
const Cell& prev = prev_buffer_.get_cell(x, y);
|
||||
|
||||
// Skip if unchanged and not forcing redraw
|
||||
if (!need_full_redraw_ && cell == prev) {
|
||||
flush_batch();
|
||||
last_x = -2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip placeholder cells (continuation of wide chars)
|
||||
if (cell.content.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否可以添加到批量输出
|
||||
bool can_batch = (y == batch_y) &&
|
||||
(x == last_x + 1 || batch_text.empty()) &&
|
||||
(cell.fg == batch_fg || batch_text.empty()) &&
|
||||
(cell.bg == batch_bg || batch_text.empty()) &&
|
||||
(cell.attrs == batch_attrs || batch_text.empty());
|
||||
|
||||
if (!can_batch) {
|
||||
flush_batch();
|
||||
batch_start_x = x;
|
||||
batch_y = y;
|
||||
batch_fg = cell.fg;
|
||||
batch_bg = cell.bg;
|
||||
batch_attrs = cell.attrs;
|
||||
}
|
||||
|
||||
batch_text += cell.content;
|
||||
last_x = x;
|
||||
}
|
||||
|
||||
// 行末刷新
|
||||
flush_batch();
|
||||
last_x = -2;
|
||||
}
|
||||
|
||||
flush_batch();
|
||||
|
||||
terminal_.reset_colors();
|
||||
terminal_.reset_attributes();
|
||||
terminal_.refresh();
|
||||
|
||||
// Copy current buffer to previous for next diff
|
||||
for (int y = 0; y < h; y++) {
|
||||
for (int x = 0; x < w; x++) {
|
||||
const_cast<FrameBuffer&>(prev_buffer_).set_cell(x, y, buffer.get_cell(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
need_full_redraw_ = false;
|
||||
}
|
||||
|
||||
void Renderer::force_redraw() {
|
||||
need_full_redraw_ = true;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "terminal.h"
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* 文本属性位标志
|
||||
*/
|
||||
enum CellAttr : uint8_t {
|
||||
ATTR_NONE = 0,
|
||||
ATTR_BOLD = 1 << 0,
|
||||
ATTR_ITALIC = 1 << 1,
|
||||
ATTR_UNDERLINE = 1 << 2,
|
||||
ATTR_REVERSE = 1 << 3,
|
||||
ATTR_DIM = 1 << 4
|
||||
};
|
||||
|
||||
/**
|
||||
* Cell - 单个字符单元格
|
||||
*
|
||||
* 存储一个UTF-8字符及其渲染属性
|
||||
*/
|
||||
struct Cell {
|
||||
std::string content; // UTF-8字符(可能1-4字节)
|
||||
uint32_t fg = 0xD0D0D0; // 前景色 (默认浅灰)
|
||||
uint32_t bg = 0x1A1A1A; // 背景色 (默认深灰)
|
||||
uint8_t attrs = ATTR_NONE;
|
||||
|
||||
bool operator==(const Cell& other) const {
|
||||
return content == other.content &&
|
||||
fg == other.fg &&
|
||||
bg == other.bg &&
|
||||
attrs == other.attrs;
|
||||
}
|
||||
|
||||
bool operator!=(const Cell& other) const {
|
||||
return !(*this == other);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* FrameBuffer - 帧缓冲区
|
||||
*
|
||||
* 双缓冲渲染:维护当前帧和上一帧,只渲染变化的部分
|
||||
*/
|
||||
class FrameBuffer {
|
||||
public:
|
||||
FrameBuffer(int width, int height);
|
||||
|
||||
void resize(int width, int height);
|
||||
void clear();
|
||||
void clear_with_color(uint32_t bg);
|
||||
|
||||
void set_cell(int x, int y, const Cell& cell);
|
||||
const Cell& get_cell(int x, int y) const;
|
||||
|
||||
// 便捷方法:设置文本(处理宽字符)
|
||||
void set_text(int x, int y, const std::string& text, uint32_t fg, uint32_t bg, uint8_t attrs = ATTR_NONE);
|
||||
|
||||
int width() const { return width_; }
|
||||
int height() const { return height_; }
|
||||
|
||||
private:
|
||||
std::vector<std::vector<Cell>> cells_;
|
||||
int width_;
|
||||
int height_;
|
||||
Cell empty_cell_;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renderer - 渲染器
|
||||
*
|
||||
* 负责将FrameBuffer的内容渲染到终端
|
||||
* 实现差分渲染以提高性能
|
||||
*/
|
||||
class Renderer {
|
||||
public:
|
||||
explicit Renderer(Terminal& terminal);
|
||||
|
||||
/**
|
||||
* 渲染帧缓冲区到终端
|
||||
* 使用差分算法只更新变化的部分
|
||||
*/
|
||||
void render(const FrameBuffer& buffer);
|
||||
|
||||
/**
|
||||
* 强制全屏重绘
|
||||
*/
|
||||
void force_redraw();
|
||||
|
||||
private:
|
||||
Terminal& terminal_;
|
||||
FrameBuffer prev_buffer_; // 上一帧,用于差分渲染
|
||||
bool need_full_redraw_ = true;
|
||||
|
||||
void apply_cell_style(const Cell& cell);
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,418 +0,0 @@
|
|||
#include "terminal.h"
|
||||
#include <ncurses.h>
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <locale.h>
|
||||
|
||||
namespace tut {
|
||||
|
||||
// ==================== Terminal::Impl ====================
|
||||
|
||||
class Terminal::Impl {
|
||||
public:
|
||||
Impl()
|
||||
: initialized_(false)
|
||||
, has_true_color_(false)
|
||||
, has_mouse_(false)
|
||||
, has_unicode_(false)
|
||||
, has_italic_(false)
|
||||
, width_(0)
|
||||
, height_(0)
|
||||
, mouse_enabled_(false)
|
||||
{}
|
||||
|
||||
~Impl() {
|
||||
if (initialized_) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
bool init() {
|
||||
if (initialized_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 设置locale以支持UTF-8
|
||||
setlocale(LC_ALL, "");
|
||||
|
||||
// 初始化ncurses
|
||||
initscr();
|
||||
if (stdscr == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 基础设置
|
||||
raw(); // 禁用行缓冲
|
||||
noecho(); // 不回显输入
|
||||
keypad(stdscr, TRUE); // 启用功能键
|
||||
nodelay(stdscr, TRUE); // 非阻塞输入(默认)
|
||||
|
||||
// 检测终端能力
|
||||
detect_capabilities();
|
||||
|
||||
// 获取屏幕尺寸
|
||||
getmaxyx(stdscr, height_, width_);
|
||||
|
||||
// 隐藏光标(默认)
|
||||
curs_set(0);
|
||||
|
||||
// 启用鼠标支持
|
||||
if (has_mouse_) {
|
||||
enable_mouse(true);
|
||||
}
|
||||
|
||||
// 使用替代屏幕缓冲区
|
||||
use_alternate_screen(true);
|
||||
|
||||
initialized_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
if (!initialized_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复光标
|
||||
curs_set(1);
|
||||
|
||||
// 禁用鼠标
|
||||
if (mouse_enabled_) {
|
||||
enable_mouse(false);
|
||||
}
|
||||
|
||||
// 退出替代屏幕
|
||||
use_alternate_screen(false);
|
||||
|
||||
// 清理ncurses
|
||||
endwin();
|
||||
|
||||
initialized_ = false;
|
||||
}
|
||||
|
||||
void detect_capabilities() {
|
||||
// 检测True Color支持
|
||||
const char* colorterm = std::getenv("COLORTERM");
|
||||
has_true_color_ = (colorterm != nullptr &&
|
||||
(std::strcmp(colorterm, "truecolor") == 0 ||
|
||||
std::strcmp(colorterm, "24bit") == 0));
|
||||
|
||||
// 检测鼠标支持
|
||||
has_mouse_ = has_mouse();
|
||||
|
||||
// 检测Unicode支持(通过locale)
|
||||
const char* lang = std::getenv("LANG");
|
||||
has_unicode_ = (lang != nullptr &&
|
||||
(std::strstr(lang, "UTF-8") != nullptr ||
|
||||
std::strstr(lang, "utf8") != nullptr));
|
||||
|
||||
// 检测斜体支持(大多数现代终端支持)
|
||||
const char* term = std::getenv("TERM");
|
||||
has_italic_ = (term != nullptr &&
|
||||
(std::strstr(term, "xterm") != nullptr ||
|
||||
std::strstr(term, "screen") != nullptr ||
|
||||
std::strstr(term, "tmux") != nullptr ||
|
||||
std::strstr(term, "kitty") != nullptr ||
|
||||
std::strstr(term, "alacritty") != nullptr));
|
||||
}
|
||||
|
||||
void get_size(int& width, int& height) {
|
||||
// 每次调用时获取最新尺寸,以支持窗口大小调整
|
||||
getmaxyx(stdscr, height_, width_);
|
||||
width = width_;
|
||||
height = height_;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
// 使用 ANSI 转义码清屏并移动光标到左上角
|
||||
std::printf("\033[2J\033[H");
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
// ANSI 模式下不需要 ncurses 的 refresh,只需 flush
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
// ==================== True Color ====================
|
||||
|
||||
void set_foreground(uint32_t rgb) {
|
||||
if (has_true_color_) {
|
||||
// ANSI escape: ESC[38;2;R;G;Bm
|
||||
int r = (rgb >> 16) & 0xFF;
|
||||
int g = (rgb >> 8) & 0xFF;
|
||||
int b = rgb & 0xFF;
|
||||
std::printf("\033[38;2;%d;%d;%dm", r, g, b);
|
||||
std::fflush(stdout);
|
||||
} else {
|
||||
// 降级到基础色(简化映射)
|
||||
// 这里可以实现256色或8色的映射
|
||||
// 暂时使用默认色
|
||||
}
|
||||
}
|
||||
|
||||
void set_background(uint32_t rgb) {
|
||||
if (has_true_color_) {
|
||||
// ANSI escape: ESC[48;2;R;G;Bm
|
||||
int r = (rgb >> 16) & 0xFF;
|
||||
int g = (rgb >> 8) & 0xFF;
|
||||
int b = rgb & 0xFF;
|
||||
std::printf("\033[48;2;%d;%d;%dm", r, g, b);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
void reset_colors() {
|
||||
// ESC[39m 重置前景色, ESC[49m 重置背景色
|
||||
std::printf("\033[39m\033[49m");
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
// ==================== 文本属性 ====================
|
||||
|
||||
void set_bold(bool enabled) {
|
||||
if (enabled) {
|
||||
std::printf("\033[1m"); // ESC[1m
|
||||
} else {
|
||||
std::printf("\033[22m"); // ESC[22m (normal intensity)
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void set_italic(bool enabled) {
|
||||
if (!has_italic_) return;
|
||||
|
||||
if (enabled) {
|
||||
std::printf("\033[3m"); // ESC[3m
|
||||
} else {
|
||||
std::printf("\033[23m"); // ESC[23m
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void set_underline(bool enabled) {
|
||||
if (enabled) {
|
||||
std::printf("\033[4m"); // ESC[4m
|
||||
} else {
|
||||
std::printf("\033[24m"); // ESC[24m
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void set_reverse(bool enabled) {
|
||||
if (enabled) {
|
||||
std::printf("\033[7m"); // ESC[7m
|
||||
} else {
|
||||
std::printf("\033[27m"); // ESC[27m
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void set_dim(bool enabled) {
|
||||
if (enabled) {
|
||||
std::printf("\033[2m"); // ESC[2m
|
||||
} else {
|
||||
std::printf("\033[22m"); // ESC[22m
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void reset_attributes() {
|
||||
std::printf("\033[0m"); // ESC[0m (reset all)
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
// ==================== 光标控制 ====================
|
||||
|
||||
void move_cursor(int x, int y) {
|
||||
// 使用 ANSI 转义码移动光标(和 printf 输出兼容)
|
||||
// 注意:ANSI 坐标是 1-indexed,所以需要 +1
|
||||
std::printf("\033[%d;%dH", y + 1, x + 1);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void hide_cursor() {
|
||||
std::printf("\033[?25l"); // DECTCEM: 隐藏光标
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void show_cursor() {
|
||||
std::printf("\033[?25h"); // DECTCEM: 显示光标
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
// ==================== 文本输出 ====================
|
||||
|
||||
void print(const std::string& text) {
|
||||
// 直接输出到stdout(配合ANSI escape sequences)
|
||||
std::printf("%s", text.c_str());
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
void print_at(int x, int y, const std::string& text) {
|
||||
move_cursor(x, y);
|
||||
print(text);
|
||||
}
|
||||
|
||||
// ==================== 输入处理 ====================
|
||||
|
||||
int get_key(int timeout_ms) {
|
||||
if (timeout_ms == -1) {
|
||||
// 阻塞等待
|
||||
nodelay(stdscr, FALSE);
|
||||
int ch = getch();
|
||||
nodelay(stdscr, TRUE);
|
||||
return ch;
|
||||
} else if (timeout_ms == 0) {
|
||||
// 非阻塞
|
||||
return getch();
|
||||
} else {
|
||||
// 超时等待
|
||||
timeout(timeout_ms);
|
||||
int ch = getch();
|
||||
nodelay(stdscr, TRUE);
|
||||
return ch;
|
||||
}
|
||||
}
|
||||
|
||||
bool get_mouse_event(MouseEvent& event) {
|
||||
if (!mouse_enabled_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
MEVENT mevent;
|
||||
int ch = getch();
|
||||
|
||||
if (ch == KEY_MOUSE) {
|
||||
if (getmouse(&mevent) == OK) {
|
||||
event.x = mevent.x;
|
||||
event.y = mevent.y;
|
||||
|
||||
// 解析鼠标事件类型
|
||||
if (mevent.bstate & BUTTON1_CLICKED) {
|
||||
event.type = MouseEvent::Type::CLICK;
|
||||
event.button = 0;
|
||||
return true;
|
||||
} else if (mevent.bstate & BUTTON2_CLICKED) {
|
||||
event.type = MouseEvent::Type::CLICK;
|
||||
event.button = 1;
|
||||
return true;
|
||||
} else if (mevent.bstate & BUTTON3_CLICKED) {
|
||||
event.type = MouseEvent::Type::CLICK;
|
||||
event.button = 2;
|
||||
return true;
|
||||
}
|
||||
#ifdef BUTTON4_PRESSED
|
||||
else if (mevent.bstate & BUTTON4_PRESSED) {
|
||||
event.type = MouseEvent::Type::SCROLL_UP;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#ifdef BUTTON5_PRESSED
|
||||
else if (mevent.bstate & BUTTON5_PRESSED) {
|
||||
event.type = MouseEvent::Type::SCROLL_DOWN;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== 终端能力 ====================
|
||||
|
||||
bool supports_true_color() const { return has_true_color_; }
|
||||
bool supports_mouse() const { return has_mouse_; }
|
||||
bool supports_unicode() const { return has_unicode_; }
|
||||
bool supports_italic() const { return has_italic_; }
|
||||
|
||||
// ==================== 高级功能 ====================
|
||||
|
||||
void enable_mouse(bool enabled) {
|
||||
if (enabled) {
|
||||
// 启用所有鼠标事件
|
||||
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, nullptr);
|
||||
// 发送启用鼠标跟踪的ANSI序列
|
||||
std::printf("\033[?1003h"); // 启用所有鼠标事件
|
||||
std::fflush(stdout);
|
||||
mouse_enabled_ = true;
|
||||
} else {
|
||||
mousemask(0, nullptr);
|
||||
std::printf("\033[?1003l"); // 禁用鼠标跟踪
|
||||
std::fflush(stdout);
|
||||
mouse_enabled_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void use_alternate_screen(bool enabled) {
|
||||
if (enabled) {
|
||||
std::printf("\033[?1049h"); // 进入替代屏幕
|
||||
} else {
|
||||
std::printf("\033[?1049l"); // 退出替代屏幕
|
||||
}
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
private:
|
||||
bool initialized_;
|
||||
bool has_true_color_;
|
||||
bool has_mouse_;
|
||||
bool has_unicode_;
|
||||
bool has_italic_;
|
||||
int width_;
|
||||
int height_;
|
||||
bool mouse_enabled_;
|
||||
};
|
||||
|
||||
// ==================== Terminal 公共接口 ====================
|
||||
|
||||
Terminal::Terminal() : pImpl(std::make_unique<Impl>()) {}
|
||||
Terminal::~Terminal() = default;
|
||||
|
||||
bool Terminal::init() { return pImpl->init(); }
|
||||
void Terminal::cleanup() { pImpl->cleanup(); }
|
||||
|
||||
void Terminal::get_size(int& width, int& height) {
|
||||
pImpl->get_size(width, height);
|
||||
}
|
||||
void Terminal::clear() { pImpl->clear(); }
|
||||
void Terminal::refresh() { pImpl->refresh(); }
|
||||
|
||||
void Terminal::set_foreground(uint32_t rgb) { pImpl->set_foreground(rgb); }
|
||||
void Terminal::set_background(uint32_t rgb) { pImpl->set_background(rgb); }
|
||||
void Terminal::reset_colors() { pImpl->reset_colors(); }
|
||||
|
||||
void Terminal::set_bold(bool enabled) { pImpl->set_bold(enabled); }
|
||||
void Terminal::set_italic(bool enabled) { pImpl->set_italic(enabled); }
|
||||
void Terminal::set_underline(bool enabled) { pImpl->set_underline(enabled); }
|
||||
void Terminal::set_reverse(bool enabled) { pImpl->set_reverse(enabled); }
|
||||
void Terminal::set_dim(bool enabled) { pImpl->set_dim(enabled); }
|
||||
void Terminal::reset_attributes() { pImpl->reset_attributes(); }
|
||||
|
||||
void Terminal::move_cursor(int x, int y) { pImpl->move_cursor(x, y); }
|
||||
void Terminal::hide_cursor() { pImpl->hide_cursor(); }
|
||||
void Terminal::show_cursor() { pImpl->show_cursor(); }
|
||||
|
||||
void Terminal::print(const std::string& text) { pImpl->print(text); }
|
||||
void Terminal::print_at(int x, int y, const std::string& text) {
|
||||
pImpl->print_at(x, y, text);
|
||||
}
|
||||
|
||||
int Terminal::get_key(int timeout_ms) { return pImpl->get_key(timeout_ms); }
|
||||
bool Terminal::get_mouse_event(MouseEvent& event) {
|
||||
return pImpl->get_mouse_event(event);
|
||||
}
|
||||
|
||||
bool Terminal::supports_true_color() const { return pImpl->supports_true_color(); }
|
||||
bool Terminal::supports_mouse() const { return pImpl->supports_mouse(); }
|
||||
bool Terminal::supports_unicode() const { return pImpl->supports_unicode(); }
|
||||
bool Terminal::supports_italic() const { return pImpl->supports_italic(); }
|
||||
|
||||
void Terminal::enable_mouse(bool enabled) { pImpl->enable_mouse(enabled); }
|
||||
void Terminal::use_alternate_screen(bool enabled) {
|
||||
pImpl->use_alternate_screen(enabled);
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
// 鼠标事件类型
|
||||
struct MouseEvent {
|
||||
enum class Type {
|
||||
CLICK,
|
||||
SCROLL_UP,
|
||||
SCROLL_DOWN,
|
||||
MOVE,
|
||||
DRAG
|
||||
};
|
||||
|
||||
Type type;
|
||||
int x;
|
||||
int y;
|
||||
int button; // 0=left, 1=middle, 2=right
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminal - 现代终端抽象层
|
||||
*
|
||||
* 提供True Color (24-bit RGB)支持的终端接口
|
||||
* 目标终端: iTerm2, Kitty, Alacritty等现代终端
|
||||
*
|
||||
* 设计理念:
|
||||
* - 优先使用ANSI escape sequences而非ncurses color pairs (突破256色限制)
|
||||
* - 检测终端能力并自动降级
|
||||
* - 提供清晰的、面向对象的API
|
||||
*/
|
||||
class Terminal {
|
||||
public:
|
||||
Terminal();
|
||||
~Terminal();
|
||||
|
||||
// ==================== 初始化与清理 ====================
|
||||
|
||||
/**
|
||||
* 初始化终端
|
||||
* - 设置原始模式
|
||||
* - 检测终端能力
|
||||
* - 启用鼠标支持(如果可用)
|
||||
* @return 是否成功初始化
|
||||
*/
|
||||
bool init();
|
||||
|
||||
/**
|
||||
* 清理并恢复终端状态
|
||||
*/
|
||||
void cleanup();
|
||||
|
||||
// ==================== 屏幕管理 ====================
|
||||
|
||||
/**
|
||||
* 获取终端尺寸(每次调用都会获取最新尺寸)
|
||||
*/
|
||||
void get_size(int& width, int& height);
|
||||
|
||||
/**
|
||||
* 清空屏幕
|
||||
*/
|
||||
void clear();
|
||||
|
||||
/**
|
||||
* 刷新显示(将缓冲区内容显示到屏幕)
|
||||
*/
|
||||
void refresh();
|
||||
|
||||
// ==================== True Color 支持 ====================
|
||||
|
||||
/**
|
||||
* 设置前景色 (24-bit RGB)
|
||||
* @param rgb RGB颜色值,格式: 0xRRGGBB
|
||||
* 示例: 0xE8C48C (暖金色)
|
||||
*/
|
||||
void set_foreground(uint32_t rgb);
|
||||
|
||||
/**
|
||||
* 设置背景色 (24-bit RGB)
|
||||
* @param rgb RGB颜色值,格式: 0xRRGGBB
|
||||
*/
|
||||
void set_background(uint32_t rgb);
|
||||
|
||||
/**
|
||||
* 重置颜色为默认值
|
||||
*/
|
||||
void reset_colors();
|
||||
|
||||
// ==================== 文本属性 ====================
|
||||
|
||||
/**
|
||||
* 设置粗体
|
||||
*/
|
||||
void set_bold(bool enabled);
|
||||
|
||||
/**
|
||||
* 设置斜体
|
||||
*/
|
||||
void set_italic(bool enabled);
|
||||
|
||||
/**
|
||||
* 设置下划线
|
||||
*/
|
||||
void set_underline(bool enabled);
|
||||
|
||||
/**
|
||||
* 设置反色显示
|
||||
*/
|
||||
void set_reverse(bool enabled);
|
||||
|
||||
/**
|
||||
* 设置暗淡显示
|
||||
*/
|
||||
void set_dim(bool enabled);
|
||||
|
||||
/**
|
||||
* 重置所有文本属性
|
||||
*/
|
||||
void reset_attributes();
|
||||
|
||||
// ==================== 光标控制 ====================
|
||||
|
||||
/**
|
||||
* 移动光标到指定位置
|
||||
* @param x 列位置 (0-based)
|
||||
* @param y 行位置 (0-based)
|
||||
*/
|
||||
void move_cursor(int x, int y);
|
||||
|
||||
/**
|
||||
* 隐藏光标
|
||||
*/
|
||||
void hide_cursor();
|
||||
|
||||
/**
|
||||
* 显示光标
|
||||
*/
|
||||
void show_cursor();
|
||||
|
||||
// ==================== 文本输出 ====================
|
||||
|
||||
/**
|
||||
* 在当前光标位置输出文本
|
||||
*/
|
||||
void print(const std::string& text);
|
||||
|
||||
/**
|
||||
* 在指定位置输出文本
|
||||
*/
|
||||
void print_at(int x, int y, const std::string& text);
|
||||
|
||||
// ==================== 输入处理 ====================
|
||||
|
||||
/**
|
||||
* 获取按键
|
||||
* @param timeout_ms 超时时间(毫秒),-1表示阻塞等待
|
||||
* @return 按键代码,超时返回-1
|
||||
*/
|
||||
int get_key(int timeout_ms = -1);
|
||||
|
||||
/**
|
||||
* 获取鼠标事件
|
||||
* @param event 输出参数,存储鼠标事件
|
||||
* @return 是否成功获取鼠标事件
|
||||
*/
|
||||
bool get_mouse_event(MouseEvent& event);
|
||||
|
||||
// ==================== 终端能力检测 ====================
|
||||
|
||||
/**
|
||||
* 是否支持True Color (24-bit)
|
||||
* 检测方法: 环境变量 COLORTERM=truecolor 或 COLORTERM=24bit
|
||||
*/
|
||||
bool supports_true_color() const;
|
||||
|
||||
/**
|
||||
* 是否支持鼠标
|
||||
*/
|
||||
bool supports_mouse() const;
|
||||
|
||||
/**
|
||||
* 是否支持Unicode
|
||||
*/
|
||||
bool supports_unicode() const;
|
||||
|
||||
/**
|
||||
* 是否支持斜体
|
||||
*/
|
||||
bool supports_italic() const;
|
||||
|
||||
// ==================== 高级功能 ====================
|
||||
|
||||
/**
|
||||
* 启用/禁用鼠标支持
|
||||
*/
|
||||
void enable_mouse(bool enabled);
|
||||
|
||||
/**
|
||||
* 启用/禁用替代屏幕缓冲区
|
||||
* (用于全屏应用,退出时恢复原屏幕内容)
|
||||
*/
|
||||
void use_alternate_screen(bool enabled);
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
|
||||
// 禁止拷贝
|
||||
Terminal(const Terminal&) = delete;
|
||||
Terminal& operator=(const Terminal&) = delete;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# TUT Browser Interactive Test Script
|
||||
# This script helps test the browser with various real websites
|
||||
|
||||
echo "========================================"
|
||||
echo " TUT 2.0 Browser Interactive Testing"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "This script will help you test the browser with real websites."
|
||||
echo "Press Ctrl+C to exit at any time."
|
||||
echo ""
|
||||
|
||||
# Build the browser
|
||||
echo "Building the browser..."
|
||||
cmake -B build -S . -DCMAKE_BUILD_TYPE=Debug > /dev/null 2>&1
|
||||
cmake --build build > /dev/null 2>&1
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Build successful!"
|
||||
echo ""
|
||||
|
||||
# Test sites
|
||||
declare -a sites=(
|
||||
"http://example.com"
|
||||
"http://info.cern.ch"
|
||||
"http://motherfuckingwebsite.com"
|
||||
"http://textfiles.com"
|
||||
)
|
||||
|
||||
echo "Available test sites:"
|
||||
echo "1. example.com - Simple static page"
|
||||
echo "2. info.cern.ch - First website ever (historical)"
|
||||
echo "3. motherfuckingwebsite.com - Minimalist design manifesto"
|
||||
echo "4. textfiles.com - Text-only content"
|
||||
echo "5. Custom URL"
|
||||
echo ""
|
||||
|
||||
read -p "Select a site (1-5): " choice
|
||||
|
||||
case $choice in
|
||||
1) url="${sites[0]}" ;;
|
||||
2) url="${sites[1]}" ;;
|
||||
3) url="${sites[2]}" ;;
|
||||
4) url="${sites[3]}" ;;
|
||||
5)
|
||||
read -p "Enter URL (include http://): " url
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice, using example.com"
|
||||
url="${sites[0]}"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " Launching TUT Browser"
|
||||
echo "========================================"
|
||||
echo "URL: $url"
|
||||
echo ""
|
||||
echo "Keyboard shortcuts:"
|
||||
echo " j/k - Scroll up/down"
|
||||
echo " Tab - Next link/field"
|
||||
echo " Enter - Follow link/activate field"
|
||||
echo " i - Focus first form field"
|
||||
echo " / - Search"
|
||||
echo " h/l - Back/Forward"
|
||||
echo " B - Bookmark"
|
||||
echo " :o URL - Open URL"
|
||||
echo " :q - Quit"
|
||||
echo ""
|
||||
read -p "Press Enter to start..."
|
||||
|
||||
# Launch the browser
|
||||
./build/tut "$url"
|
||||
|
||||
echo ""
|
||||
echo "Browser exited. Test complete!"
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Form Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Form</h1>
|
||||
|
||||
<form action="/submit" method="POST">
|
||||
<p>Name: <input type="text" name="name" placeholder="Enter your name"></p>
|
||||
<p>Email: <input type="text" name="email" placeholder="email@example.com"></p>
|
||||
<p>Password: <input type="password" name="password"></p>
|
||||
|
||||
<p>Country: <select name="country">
|
||||
<option value="">Select a country</option>
|
||||
<option value="us">United States</option>
|
||||
<option value="uk">United Kingdom</option>
|
||||
<option value="ca">Canada</option>
|
||||
<option value="au">Australia</option>
|
||||
</select></p>
|
||||
|
||||
<p>Age Group: <select name="age">
|
||||
<option value="18-25" selected>18-25</option>
|
||||
<option value="26-35">26-35</option>
|
||||
<option value="36-50">36-50</option>
|
||||
<option value="51+">51+</option>
|
||||
</select></p>
|
||||
|
||||
<p><input type="checkbox" name="subscribe"> Subscribe to newsletter</p>
|
||||
<p><input type="checkbox" name="agree"> I agree to terms</p>
|
||||
|
||||
<p><input type="submit" value="Submit Form"></p>
|
||||
<p><button>Click Me</button></p>
|
||||
</form>
|
||||
|
||||
<h2>Links</h2>
|
||||
<p><a href="https://example.com">Example Link</a></p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Real-world browser testing script
|
||||
# Tests TUT with various website types and provides UX feedback
|
||||
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " TUT Real-World Browser Testing"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "This script will test TUT with various website types:"
|
||||
echo ""
|
||||
echo "1. News sites (text-heavy, many images)"
|
||||
echo "2. Documentation (code blocks, technical content)"
|
||||
echo "3. Simple static sites (basic HTML)"
|
||||
echo "4. Image galleries (many concurrent images)"
|
||||
echo "5. Forums/discussions (mixed content)"
|
||||
echo ""
|
||||
echo "For each site, we'll evaluate:"
|
||||
echo " • Loading speed and responsiveness"
|
||||
echo " • Image loading behavior"
|
||||
echo " • Content readability"
|
||||
echo " • Navigation smoothness"
|
||||
echo " • Overall user experience"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Test categories
|
||||
declare -A SITES
|
||||
|
||||
# Category 1: News/Content sites
|
||||
SITES["news_hn"]="https://news.ycombinator.com"
|
||||
SITES["news_lobsters"]="https://lobste.rs"
|
||||
|
||||
# Category 2: Documentation
|
||||
SITES["doc_curl"]="https://curl.se/docs/manpage.html"
|
||||
SITES["doc_rust"]="https://doc.rust-lang.org/book/ch01-01-installation.html"
|
||||
|
||||
# Category 3: Simple/Static
|
||||
SITES["simple_example"]="https://example.com"
|
||||
SITES["simple_motherfuckingwebsite"]="https://motherfuckingwebsite.com"
|
||||
|
||||
# Category 4: Wikipedia (images + content)
|
||||
SITES["wiki_unix"]="https://en.wikipedia.org/wiki/Unix"
|
||||
SITES["wiki_web"]="https://en.wikipedia.org/wiki/World_Wide_Web"
|
||||
|
||||
# Category 5: Tech blogs
|
||||
SITES["blog_lwn"]="https://lwn.net"
|
||||
|
||||
echo "Available test sites:"
|
||||
echo ""
|
||||
echo "News & Content:"
|
||||
echo " 1. Hacker News - ${SITES[news_hn]}"
|
||||
echo " 2. Lobsters - ${SITES[news_lobsters]}"
|
||||
echo ""
|
||||
echo "Documentation:"
|
||||
echo " 3. curl manual - ${SITES[doc_curl]}"
|
||||
echo " 4. Rust Book - ${SITES[doc_rust]}"
|
||||
echo ""
|
||||
echo "Simple Sites:"
|
||||
echo " 5. Example.com - ${SITES[simple_example]}"
|
||||
echo " 6. Motherfucking Web - ${SITES[simple_motherfuckingwebsite]}"
|
||||
echo ""
|
||||
echo "Wikipedia:"
|
||||
echo " 7. Unix - ${SITES[wiki_unix]}"
|
||||
echo " 8. World Wide Web - ${SITES[wiki_web]}"
|
||||
echo ""
|
||||
echo "Tech News:"
|
||||
echo " 9. LWN.net - ${SITES[blog_lwn]}"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Function to test a site
|
||||
test_site() {
|
||||
local name=$1
|
||||
local url=$2
|
||||
|
||||
echo ""
|
||||
echo "──────────────────────────────────────────────────────────"
|
||||
echo "Testing: $name"
|
||||
echo "URL: $url"
|
||||
echo "──────────────────────────────────────────────────────────"
|
||||
echo ""
|
||||
echo "Starting browser... (Press 'q' to quit and move to next)"
|
||||
echo ""
|
||||
|
||||
./build/tut "$url"
|
||||
|
||||
echo ""
|
||||
echo "Test completed for: $name"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Interactive mode
|
||||
echo "Select test mode:"
|
||||
echo " a) Test all sites automatically"
|
||||
echo " m) Manual site selection"
|
||||
echo " q) Quit"
|
||||
echo ""
|
||||
read -p "Choice: " choice
|
||||
|
||||
case $choice in
|
||||
a)
|
||||
echo ""
|
||||
echo "Running automated tests..."
|
||||
echo "Note: Each site will open. Press 'q' to move to next."
|
||||
echo ""
|
||||
sleep 2
|
||||
|
||||
test_site "Hacker News" "${SITES[news_hn]}"
|
||||
test_site "Example.com" "${SITES[simple_example]}"
|
||||
test_site "Wikipedia - Unix" "${SITES[wiki_unix]}"
|
||||
;;
|
||||
m)
|
||||
echo ""
|
||||
read -p "Enter site number (1-9): " num
|
||||
case $num in
|
||||
1) test_site "Hacker News" "${SITES[news_hn]}" ;;
|
||||
2) test_site "Lobsters" "${SITES[news_lobsters]}" ;;
|
||||
3) test_site "curl manual" "${SITES[doc_curl]}" ;;
|
||||
4) test_site "Rust Book" "${SITES[doc_rust]}" ;;
|
||||
5) test_site "Example.com" "${SITES[simple_example]}" ;;
|
||||
6) test_site "Motherfucking Website" "${SITES[simple_motherfuckingwebsite]}" ;;
|
||||
7) test_site "Wikipedia - Unix" "${SITES[wiki_unix]}" ;;
|
||||
8) test_site "Wikipedia - WWW" "${SITES[wiki_web]}" ;;
|
||||
9) test_site "LWN.net" "${SITES[blog_lwn]}" ;;
|
||||
*) echo "Invalid selection" ;;
|
||||
esac
|
||||
;;
|
||||
q)
|
||||
echo "Exiting..."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Invalid choice"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " Testing Complete!"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
#include "../src/http_client.h"
|
||||
#include <iostream>
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
void test_async_image_downloads() {
|
||||
std::cout << "Testing async image downloads...\n";
|
||||
|
||||
HttpClient client;
|
||||
|
||||
// Add multiple image downloads
|
||||
// Using small test images from httpbin.org
|
||||
const char* test_images[] = {
|
||||
"https://httpbin.org/image/png",
|
||||
"https://httpbin.org/image/jpeg",
|
||||
"https://httpbin.org/image/webp"
|
||||
};
|
||||
|
||||
// Add images to queue
|
||||
for (int i = 0; i < 3; i++) {
|
||||
client.add_image_download(test_images[i], (void*)(intptr_t)i);
|
||||
std::cout << " Queued: " << test_images[i] << "\n";
|
||||
}
|
||||
|
||||
std::cout << " Pending: " << client.get_pending_image_count() << "\n";
|
||||
assert(client.get_pending_image_count() == 3);
|
||||
|
||||
// Poll until all images are downloaded
|
||||
int iterations = 0;
|
||||
int max_iterations = 200; // 10 seconds max (50ms * 200)
|
||||
|
||||
while ((client.get_pending_image_count() > 0 ||
|
||||
client.get_loading_image_count() > 0) &&
|
||||
iterations < max_iterations) {
|
||||
client.poll_image_downloads();
|
||||
|
||||
// Check for completed images
|
||||
auto completed = client.get_completed_images();
|
||||
for (const auto& img : completed) {
|
||||
std::cout << " Downloaded: " << img.url
|
||||
<< " (status: " << img.status_code
|
||||
<< ", size: " << img.data.size() << " bytes)\n";
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
iterations++;
|
||||
}
|
||||
|
||||
std::cout << " Completed after " << iterations << " iterations\n";
|
||||
std::cout << " Final state - Pending: " << client.get_pending_image_count()
|
||||
<< ", Loading: " << client.get_loading_image_count() << "\n";
|
||||
|
||||
std::cout << "✓ Async image download test passed!\n\n";
|
||||
}
|
||||
|
||||
void test_image_cancellation() {
|
||||
std::cout << "Testing image download cancellation...\n";
|
||||
|
||||
HttpClient client;
|
||||
|
||||
// Add images
|
||||
client.add_image_download("https://httpbin.org/image/png", nullptr);
|
||||
client.add_image_download("https://httpbin.org/image/jpeg", nullptr);
|
||||
|
||||
std::cout << " Queued 2 images\n";
|
||||
|
||||
// Start downloads
|
||||
client.poll_image_downloads();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
// Cancel all
|
||||
std::cout << " Cancelling all downloads\n";
|
||||
client.cancel_all_images();
|
||||
|
||||
assert(client.get_pending_image_count() == 0);
|
||||
assert(client.get_loading_image_count() == 0);
|
||||
|
||||
std::cout << "✓ Image cancellation test passed!\n\n";
|
||||
}
|
||||
|
||||
void test_concurrent_limit() {
|
||||
std::cout << "Testing concurrent download limit...\n";
|
||||
|
||||
HttpClient client;
|
||||
client.set_max_concurrent_images(2);
|
||||
|
||||
// Add 5 images
|
||||
for (int i = 0; i < 5; i++) {
|
||||
client.add_image_download("https://httpbin.org/delay/1", nullptr);
|
||||
}
|
||||
|
||||
std::cout << " Queued 5 images with max_concurrent=2\n";
|
||||
assert(client.get_pending_image_count() == 5);
|
||||
|
||||
// Start downloads
|
||||
client.poll_image_downloads();
|
||||
|
||||
// Should have max 2 loading at a time
|
||||
int loading = client.get_loading_image_count();
|
||||
std::cout << " Loading: " << loading << " (should be <= 2)\n";
|
||||
assert(loading <= 2);
|
||||
|
||||
client.cancel_all_images();
|
||||
std::cout << "✓ Concurrent limit test passed!\n\n";
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::cout << "=== Async Image Download Tests ===\n\n";
|
||||
|
||||
try {
|
||||
test_async_image_downloads();
|
||||
test_image_cancellation();
|
||||
test_concurrent_limit();
|
||||
|
||||
std::cout << "=== All tests passed! ===\n";
|
||||
return 0;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Test failed: " << e.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
#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;
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
#include "../src/http_client.h"
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
int main() {
|
||||
std::cout << "=== Minimal Async Image Test ===\n";
|
||||
|
||||
try {
|
||||
HttpClient client;
|
||||
std::cout << "1. Client created\n";
|
||||
|
||||
client.add_image_download("https://httpbin.org/image/png", nullptr);
|
||||
std::cout << "2. Image queued\n";
|
||||
std::cout << " Pending: " << client.get_pending_image_count() << "\n";
|
||||
|
||||
std::cout << "3. First poll...\n";
|
||||
client.poll_image_downloads();
|
||||
std::cout << " After poll - Pending: " << client.get_pending_image_count()
|
||||
<< ", Loading: " << client.get_loading_image_count() << "\n";
|
||||
|
||||
std::cout << "4. Wait a bit...\n";
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
|
||||
std::cout << "5. Second poll...\n";
|
||||
client.poll_image_downloads();
|
||||
std::cout << " After poll - Pending: " << client.get_pending_image_count()
|
||||
<< ", Loading: " << client.get_loading_image_count() << "\n";
|
||||
|
||||
std::cout << "6. Get completed...\n";
|
||||
auto completed = client.get_completed_images();
|
||||
std::cout << " Completed: " << completed.size() << "\n";
|
||||
|
||||
std::cout << "7. Destroying client...\n";
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "ERROR: " << e.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "8. Test completed successfully!\n";
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
/**
|
||||
* test_layout.cpp - Layout引擎测试
|
||||
*
|
||||
* 测试内容:
|
||||
* 1. DOM树构建
|
||||
* 2. 布局计算
|
||||
* 3. 文档渲染演示
|
||||
*/
|
||||
|
||||
#include "render/terminal.h"
|
||||
#include "render/renderer.h"
|
||||
#include "render/layout.h"
|
||||
#include "render/colors.h"
|
||||
#include "dom_tree.h"
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <ncurses.h>
|
||||
|
||||
using namespace tut;
|
||||
|
||||
void test_image_placeholder() {
|
||||
std::cout << "=== 图片占位符测试 ===\n";
|
||||
|
||||
std::string html = R"(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>图片测试</title></head>
|
||||
<body>
|
||||
<h1>图片测试页面</h1>
|
||||
<p>下面是一些图片:</p>
|
||||
<img src="https://example.com/photo.png" alt="Example Photo" />
|
||||
<p>中间文本</p>
|
||||
<img src="logo.jpg" />
|
||||
<img alt="Only alt text" />
|
||||
<img />
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
DomTreeBuilder builder;
|
||||
DocumentTree doc = builder.build(html, "test://");
|
||||
|
||||
LayoutEngine engine(80);
|
||||
LayoutResult layout = engine.layout(doc);
|
||||
|
||||
std::cout << "图片测试 - 总块数: " << layout.blocks.size() << "\n";
|
||||
std::cout << "图片测试 - 总行数: " << layout.total_lines << "\n";
|
||||
|
||||
// 检查渲染输出
|
||||
int img_count = 0;
|
||||
for (const auto& block : layout.blocks) {
|
||||
if (block.type == ElementType::IMAGE) {
|
||||
img_count++;
|
||||
if (!block.lines.empty() && !block.lines[0].spans.empty()) {
|
||||
std::cout << " 图片 " << img_count << ": " << block.lines[0].spans[0].text << "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
std::cout << "找到 " << img_count << " 个图片块\n\n";
|
||||
}
|
||||
|
||||
void test_layout_basic() {
|
||||
std::cout << "=== Layout 基础测试 ===\n";
|
||||
|
||||
// 测试HTML
|
||||
std::string html = R"(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>测试页面</title></head>
|
||||
<body>
|
||||
<h1>TUT 2.0 布局引擎测试</h1>
|
||||
<p>这是一个段落,用于测试文本换行功能。当文本超过视口宽度时,应该自动换行到下一行。</p>
|
||||
<h2>列表测试</h2>
|
||||
<ul>
|
||||
<li>无序列表项目 1</li>
|
||||
<li>无序列表项目 2</li>
|
||||
<li>无序列表项目 3</li>
|
||||
</ul>
|
||||
<h2>链接测试</h2>
|
||||
<p>这是一个 <a href="https://example.com">链接示例</a>,点击可以访问。</p>
|
||||
<blockquote>这是一段引用文本,应该带有左边框标记。</blockquote>
|
||||
<hr>
|
||||
<p>页面结束。</p>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
// 构建DOM树
|
||||
DomTreeBuilder builder;
|
||||
DocumentTree doc = builder.build(html, "test://");
|
||||
std::cout << "DOM树构建: OK\n";
|
||||
std::cout << "标题: " << doc.title << "\n";
|
||||
std::cout << "链接数: " << doc.links.size() << "\n";
|
||||
|
||||
// 布局计算
|
||||
LayoutEngine engine(80);
|
||||
LayoutResult layout = engine.layout(doc);
|
||||
std::cout << "布局计算: OK\n";
|
||||
std::cout << "布局块数: " << layout.blocks.size() << "\n";
|
||||
std::cout << "总行数: " << layout.total_lines << "\n";
|
||||
|
||||
// 打印布局块信息
|
||||
std::cout << "\n布局块详情:\n";
|
||||
int block_num = 0;
|
||||
for (const auto& block : layout.blocks) {
|
||||
std::cout << " Block " << block_num++ << ": "
|
||||
<< block.lines.size() << " lines, "
|
||||
<< "margin_top=" << block.margin_top << ", "
|
||||
<< "margin_bottom=" << block.margin_bottom << "\n";
|
||||
}
|
||||
|
||||
std::cout << "\nLayout 基础测试完成!\n";
|
||||
}
|
||||
|
||||
void demo_layout_render(Terminal& term) {
|
||||
int w, h;
|
||||
term.get_size(w, h);
|
||||
|
||||
// 创建测试HTML
|
||||
std::string html = R"(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>TUT 2.0 布局演示</title></head>
|
||||
<body>
|
||||
<h1>TUT 2.0 - 终端浏览器</h1>
|
||||
|
||||
<p>这是一个现代化的终端浏览器,支持 True Color 渲染、Unicode 字符以及差分渲染优化。</p>
|
||||
|
||||
<h2>主要特性</h2>
|
||||
<ul>
|
||||
<li>True Color 24位色彩支持</li>
|
||||
<li>Unicode 字符正确显示(包括CJK字符)</li>
|
||||
<li>差分渲染提升性能</li>
|
||||
<li>温暖护眼的配色方案</li>
|
||||
</ul>
|
||||
|
||||
<h2>链接示例</h2>
|
||||
<p>访问 <a href="https://example.com">Example</a> 或 <a href="https://github.com">GitHub</a> 了解更多信息。</p>
|
||||
|
||||
<h3>引用块</h3>
|
||||
<blockquote>Unix哲学:做一件事,把它做好。</blockquote>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>使用 j/k 滚动,q 退出。</p>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
// 构建DOM树
|
||||
DomTreeBuilder builder;
|
||||
DocumentTree doc = builder.build(html, "demo://");
|
||||
|
||||
// 布局计算
|
||||
LayoutEngine engine(w);
|
||||
LayoutResult layout = engine.layout(doc);
|
||||
|
||||
// 创建帧缓冲区和渲染器
|
||||
FrameBuffer fb(w, h - 2); // 留出状态栏空间
|
||||
Renderer renderer(term);
|
||||
DocumentRenderer doc_renderer(fb);
|
||||
|
||||
int scroll_offset = 0;
|
||||
int max_scroll = std::max(0, layout.total_lines - (h - 2));
|
||||
int active_link = -1;
|
||||
int num_links = static_cast<int>(doc.links.size());
|
||||
|
||||
bool running = true;
|
||||
while (running) {
|
||||
// 清空缓冲区
|
||||
fb.clear_with_color(colors::BG_PRIMARY);
|
||||
|
||||
// 渲染文档
|
||||
RenderContext render_ctx;
|
||||
render_ctx.active_link = active_link;
|
||||
doc_renderer.render(layout, scroll_offset, render_ctx);
|
||||
|
||||
// 渲染状态栏
|
||||
std::string status = layout.title + " | 行 " + std::to_string(scroll_offset + 1) +
|
||||
"/" + std::to_string(layout.total_lines);
|
||||
if (active_link >= 0 && active_link < num_links) {
|
||||
status += " | 链接: " + doc.links[active_link].url;
|
||||
}
|
||||
// 截断过长的状态栏
|
||||
if (Unicode::display_width(status) > static_cast<size_t>(w - 2)) {
|
||||
status = status.substr(0, w - 5) + "...";
|
||||
}
|
||||
|
||||
// 状态栏在最后一行
|
||||
for (int x = 0; x < w; ++x) {
|
||||
fb.set_cell(x, h - 2, Cell{" ", colors::STATUSBAR_FG, colors::STATUSBAR_BG, ATTR_NONE});
|
||||
}
|
||||
fb.set_text(1, h - 2, status, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
||||
|
||||
// 渲染到终端
|
||||
renderer.render(fb);
|
||||
|
||||
// 处理输入
|
||||
int key = term.get_key(100);
|
||||
switch (key) {
|
||||
case 'q':
|
||||
case 'Q':
|
||||
running = false;
|
||||
break;
|
||||
case 'j':
|
||||
case KEY_DOWN:
|
||||
if (scroll_offset < max_scroll) scroll_offset++;
|
||||
break;
|
||||
case 'k':
|
||||
case KEY_UP:
|
||||
if (scroll_offset > 0) scroll_offset--;
|
||||
break;
|
||||
case ' ':
|
||||
case KEY_NPAGE:
|
||||
scroll_offset = std::min(scroll_offset + (h - 3), max_scroll);
|
||||
break;
|
||||
case 'b':
|
||||
case KEY_PPAGE:
|
||||
scroll_offset = std::max(scroll_offset - (h - 3), 0);
|
||||
break;
|
||||
case 'g':
|
||||
case KEY_HOME:
|
||||
scroll_offset = 0;
|
||||
break;
|
||||
case 'G':
|
||||
case KEY_END:
|
||||
scroll_offset = max_scroll;
|
||||
break;
|
||||
case '\t': // Tab键切换链接
|
||||
if (num_links > 0) {
|
||||
active_link = (active_link + 1) % num_links;
|
||||
}
|
||||
break;
|
||||
case KEY_BTAB: // Shift+Tab
|
||||
if (num_links > 0) {
|
||||
active_link = (active_link - 1 + num_links) % num_links;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
// 先运行非终端测试
|
||||
test_image_placeholder();
|
||||
test_layout_basic();
|
||||
|
||||
std::cout << "\n按回车键进入交互演示 (或 Ctrl+C 退出)...\n";
|
||||
std::cin.get();
|
||||
|
||||
// 交互演示
|
||||
Terminal term;
|
||||
if (!term.init()) {
|
||||
std::cerr << "终端初始化失败!\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
term.use_alternate_screen(true);
|
||||
term.hide_cursor();
|
||||
|
||||
demo_layout_render(term);
|
||||
|
||||
term.show_cursor();
|
||||
term.use_alternate_screen(false);
|
||||
term.cleanup();
|
||||
|
||||
std::cout << "Layout 测试完成!\n";
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
/**
|
||||
* test_renderer.cpp - FrameBuffer 和 Renderer 测试
|
||||
*
|
||||
* 测试内容:
|
||||
* 1. Unicode字符宽度计算
|
||||
* 2. FrameBuffer操作
|
||||
* 3. 差分渲染演示
|
||||
*/
|
||||
|
||||
#include "render/terminal.h"
|
||||
#include "render/renderer.h"
|
||||
#include "render/colors.h"
|
||||
#include "render/decorations.h"
|
||||
#include "utils/unicode.h"
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
using namespace tut;
|
||||
|
||||
void test_unicode() {
|
||||
std::cout << "=== Unicode 测试 ===\n";
|
||||
|
||||
// 测试用例
|
||||
struct TestCase {
|
||||
std::string text;
|
||||
size_t expected_width;
|
||||
const char* description;
|
||||
};
|
||||
|
||||
TestCase tests[] = {
|
||||
{"Hello", 5, "ASCII"},
|
||||
{"你好", 4, "中文(2字符,宽度4)"},
|
||||
{"Hello世界", 9, "混合ASCII+中文"},
|
||||
{"🎉", 2, "Emoji"},
|
||||
{"café", 4, "带重音符号"},
|
||||
};
|
||||
|
||||
bool all_passed = true;
|
||||
for (const auto& tc : tests) {
|
||||
size_t width = Unicode::display_width(tc.text);
|
||||
bool pass = (width == tc.expected_width);
|
||||
std::cout << (pass ? "[OK] " : "[FAIL] ")
|
||||
<< tc.description << ": \"" << tc.text << "\" "
|
||||
<< "width=" << width
|
||||
<< " (expected " << tc.expected_width << ")\n";
|
||||
if (!pass) all_passed = false;
|
||||
}
|
||||
|
||||
std::cout << (all_passed ? "\n所有Unicode测试通过!\n" : "\n部分测试失败!\n");
|
||||
}
|
||||
|
||||
void test_framebuffer() {
|
||||
std::cout << "\n=== FrameBuffer 测试 ===\n";
|
||||
|
||||
FrameBuffer fb(80, 24);
|
||||
std::cout << "创建 80x24 FrameBuffer: OK\n";
|
||||
|
||||
// 测试set_text
|
||||
fb.set_text(0, 0, "Hello World", colors::FG_PRIMARY, colors::BG_PRIMARY);
|
||||
std::cout << "set_text ASCII: OK\n";
|
||||
|
||||
fb.set_text(0, 1, "你好世界", colors::H1_FG, colors::BG_PRIMARY);
|
||||
std::cout << "set_text 中文: OK\n";
|
||||
|
||||
// 验证单元格
|
||||
const Cell& cell = fb.get_cell(0, 0);
|
||||
if (cell.content == "H" && cell.fg == colors::FG_PRIMARY) {
|
||||
std::cout << "get_cell 验证: OK\n";
|
||||
} else {
|
||||
std::cout << "get_cell 验证: FAIL\n";
|
||||
}
|
||||
|
||||
std::cout << "FrameBuffer 测试完成!\n";
|
||||
}
|
||||
|
||||
void demo_renderer(Terminal& term) {
|
||||
int w, h;
|
||||
term.get_size(w, h);
|
||||
|
||||
FrameBuffer fb(w, h);
|
||||
Renderer renderer(term);
|
||||
|
||||
// 清屏并显示标题
|
||||
fb.clear_with_color(colors::BG_PRIMARY);
|
||||
|
||||
// 标题
|
||||
std::string title = "TUT 2.0 - Renderer Demo";
|
||||
int title_x = (w - Unicode::display_width(title)) / 2;
|
||||
fb.set_text(title_x, 1, title, colors::H1_FG, colors::BG_PRIMARY, ATTR_BOLD);
|
||||
|
||||
// 分隔线
|
||||
std::string line = make_horizontal_line(w - 4, chars::SGL_HORIZONTAL);
|
||||
fb.set_text(2, 2, line, colors::BORDER, colors::BG_PRIMARY);
|
||||
|
||||
// 颜色示例
|
||||
fb.set_text(2, 4, "颜色示例:", colors::FG_PRIMARY, colors::BG_PRIMARY, ATTR_BOLD);
|
||||
fb.set_text(4, 5, chars::BULLET + std::string(" H1标题色"), colors::H1_FG, colors::BG_PRIMARY);
|
||||
fb.set_text(4, 6, chars::BULLET + std::string(" H2标题色"), colors::H2_FG, colors::BG_PRIMARY);
|
||||
fb.set_text(4, 7, chars::BULLET + std::string(" H3标题色"), colors::H3_FG, colors::BG_PRIMARY);
|
||||
fb.set_text(4, 8, chars::BULLET + std::string(" 链接色"), colors::LINK_FG, colors::BG_PRIMARY);
|
||||
|
||||
// 装饰字符示例
|
||||
fb.set_text(2, 10, "装饰字符:", colors::FG_PRIMARY, colors::BG_PRIMARY, ATTR_BOLD);
|
||||
fb.set_text(4, 11, std::string(chars::DBL_TOP_LEFT) + make_horizontal_line(20, chars::DBL_HORIZONTAL) + chars::DBL_TOP_RIGHT,
|
||||
colors::BORDER, colors::BG_PRIMARY);
|
||||
fb.set_text(4, 12, std::string(chars::DBL_VERTICAL) + " 双线边框示例 " + chars::DBL_VERTICAL,
|
||||
colors::BORDER, colors::BG_PRIMARY);
|
||||
fb.set_text(4, 13, std::string(chars::DBL_BOTTOM_LEFT) + make_horizontal_line(20, chars::DBL_HORIZONTAL) + chars::DBL_BOTTOM_RIGHT,
|
||||
colors::BORDER, colors::BG_PRIMARY);
|
||||
|
||||
// Unicode宽度示例
|
||||
fb.set_text(2, 15, "Unicode宽度:", colors::FG_PRIMARY, colors::BG_PRIMARY, ATTR_BOLD);
|
||||
fb.set_text(4, 16, "ASCII: Hello (5)", colors::FG_SECONDARY, colors::BG_PRIMARY);
|
||||
fb.set_text(4, 17, "中文: 你好世界 (8)", colors::FG_SECONDARY, colors::BG_PRIMARY);
|
||||
|
||||
// 提示
|
||||
fb.set_text(2, h - 2, "按 'q' 退出", colors::FG_DIM, colors::BG_PRIMARY);
|
||||
|
||||
// 渲染
|
||||
renderer.render(fb);
|
||||
|
||||
// 等待退出
|
||||
while (true) {
|
||||
int key = term.get_key(100);
|
||||
if (key == 'q' || key == 'Q') break;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
// 先运行非终端测试
|
||||
test_unicode();
|
||||
test_framebuffer();
|
||||
|
||||
std::cout << "\n按回车键进入交互演示 (或 Ctrl+C 退出)...\n";
|
||||
std::cin.get();
|
||||
|
||||
// 交互演示
|
||||
Terminal term;
|
||||
if (!term.init()) {
|
||||
std::cerr << "终端初始化失败!\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
term.use_alternate_screen(true);
|
||||
term.hide_cursor();
|
||||
|
||||
demo_renderer(term);
|
||||
|
||||
term.show_cursor();
|
||||
term.use_alternate_screen(false);
|
||||
term.cleanup();
|
||||
|
||||
std::cout << "Renderer 测试完成!\n";
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
#include "../src/http_client.h"
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
std::cout << "Testing basic image download...\n";
|
||||
|
||||
try {
|
||||
HttpClient client;
|
||||
std::cout << "Client created\n";
|
||||
|
||||
client.add_image_download("https://httpbin.org/image/png", nullptr);
|
||||
std::cout << "Image queued\n";
|
||||
std::cout << "Pending: " << client.get_pending_image_count() << "\n";
|
||||
|
||||
std::cout << "Client will be destroyed\n";
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Error: " << e.what() << "\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "Test completed successfully\n";
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
/**
|
||||
* test_terminal.cpp - Terminal类True Color功能测试
|
||||
*
|
||||
* 测试内容:
|
||||
* 1. True Color (24-bit RGB) 支持
|
||||
* 2. 文本属性 (粗体、斜体、下划线)
|
||||
* 3. Unicode字符显示
|
||||
* 4. 终端能力检测
|
||||
*/
|
||||
|
||||
#include "terminal.h"
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
|
||||
using namespace tut;
|
||||
|
||||
void test_true_color(Terminal& term) {
|
||||
term.clear();
|
||||
|
||||
// 标题
|
||||
term.move_cursor(0, 0);
|
||||
term.set_bold(true);
|
||||
term.set_foreground(0xE8C48C); // 暖金色
|
||||
term.print("TUT 2.0 - True Color Test");
|
||||
term.reset_attributes();
|
||||
|
||||
// 能力检测报告
|
||||
int y = 2;
|
||||
term.move_cursor(0, y++);
|
||||
term.print("Terminal Capabilities:");
|
||||
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" True Color: ");
|
||||
if (term.supports_true_color()) {
|
||||
term.set_foreground(0x00FF00);
|
||||
term.print("✓ Supported");
|
||||
} else {
|
||||
term.set_foreground(0xFF0000);
|
||||
term.print("✗ Not Supported");
|
||||
}
|
||||
term.reset_colors();
|
||||
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" Mouse: ");
|
||||
if (term.supports_mouse()) {
|
||||
term.set_foreground(0x00FF00);
|
||||
term.print("✓ Supported");
|
||||
} else {
|
||||
term.set_foreground(0xFF0000);
|
||||
term.print("✗ Not Supported");
|
||||
}
|
||||
term.reset_colors();
|
||||
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" Unicode: ");
|
||||
if (term.supports_unicode()) {
|
||||
term.set_foreground(0x00FF00);
|
||||
term.print("✓ Supported");
|
||||
} else {
|
||||
term.set_foreground(0xFF0000);
|
||||
term.print("✗ Not Supported");
|
||||
}
|
||||
term.reset_colors();
|
||||
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" Italic: ");
|
||||
if (term.supports_italic()) {
|
||||
term.set_foreground(0x00FF00);
|
||||
term.print("✓ Supported");
|
||||
} else {
|
||||
term.set_foreground(0xFF0000);
|
||||
term.print("✗ Not Supported");
|
||||
}
|
||||
term.reset_colors();
|
||||
|
||||
y++;
|
||||
|
||||
// 报纸风格颜色主题测试
|
||||
term.move_cursor(0, y++);
|
||||
term.set_bold(true);
|
||||
term.print("Newspaper Color Theme:");
|
||||
term.reset_attributes();
|
||||
|
||||
y++;
|
||||
|
||||
// H1 颜色
|
||||
term.move_cursor(0, y++);
|
||||
term.set_bold(true);
|
||||
term.set_foreground(0xE8C48C); // 暖金色
|
||||
term.print(" H1 Heading - Warm Gold (0xE8C48C)");
|
||||
term.reset_attributes();
|
||||
|
||||
// H2 颜色
|
||||
term.move_cursor(0, y++);
|
||||
term.set_bold(true);
|
||||
term.set_foreground(0xD4B078); // 较暗金色
|
||||
term.print(" H2 Heading - Dark Gold (0xD4B078)");
|
||||
term.reset_attributes();
|
||||
|
||||
// H3 颜色
|
||||
term.move_cursor(0, y++);
|
||||
term.set_bold(true);
|
||||
term.set_foreground(0xC09C64); // 青铜色
|
||||
term.print(" H3 Heading - Bronze (0xC09C64)");
|
||||
term.reset_attributes();
|
||||
|
||||
y++;
|
||||
|
||||
// 链接颜色
|
||||
term.move_cursor(0, y++);
|
||||
term.set_foreground(0x87AFAF); // 青色
|
||||
term.set_underline(true);
|
||||
term.print(" Link - Teal (0x87AFAF)");
|
||||
term.reset_attributes();
|
||||
|
||||
// 悬停链接
|
||||
term.move_cursor(0, y++);
|
||||
term.set_foreground(0xA7CFCF); // 浅青色
|
||||
term.set_underline(true);
|
||||
term.print(" Link Hover - Light Teal (0xA7CFCF)");
|
||||
term.reset_attributes();
|
||||
|
||||
y++;
|
||||
|
||||
// 正文颜色
|
||||
term.move_cursor(0, y++);
|
||||
term.set_foreground(0xD0D0D0); // 浅灰
|
||||
term.print(" Body Text - Light Gray (0xD0D0D0)");
|
||||
term.reset_colors();
|
||||
|
||||
// 次要文本
|
||||
term.move_cursor(0, y++);
|
||||
term.set_foreground(0x909090); // 中灰
|
||||
term.print(" Secondary Text - Medium Gray (0x909090)");
|
||||
term.reset_colors();
|
||||
|
||||
y++;
|
||||
|
||||
// Unicode装饰测试
|
||||
term.move_cursor(0, y++);
|
||||
term.set_bold(true);
|
||||
term.print("Unicode Box Drawing:");
|
||||
term.reset_attributes();
|
||||
|
||||
y++;
|
||||
|
||||
// 双线框
|
||||
term.move_cursor(0, y++);
|
||||
term.set_foreground(0x404040);
|
||||
term.print(" ╔═══════════════════════════════════╗");
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" ║ Double Border for H1 Headings ║");
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" ╚═══════════════════════════════════╝");
|
||||
term.reset_colors();
|
||||
|
||||
y++;
|
||||
|
||||
// 单线框
|
||||
term.move_cursor(0, y++);
|
||||
term.set_foreground(0x404040);
|
||||
term.print(" ┌───────────────────────────────────┐");
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" │ Single Border for Code Blocks │");
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" └───────────────────────────────────┘");
|
||||
term.reset_colors();
|
||||
|
||||
y++;
|
||||
|
||||
// 引用块
|
||||
term.move_cursor(0, y++);
|
||||
term.set_foreground(0x6A8F8F);
|
||||
term.print(" ┃ Blockquote with heavy vertical bar");
|
||||
term.reset_colors();
|
||||
|
||||
y++;
|
||||
|
||||
// 列表符号
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" • Bullet point (level 1)");
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" ◦ Circle (level 2)");
|
||||
term.move_cursor(0, y++);
|
||||
term.print(" ▪ Square (level 3)");
|
||||
|
||||
y += 2;
|
||||
|
||||
// 提示
|
||||
term.move_cursor(0, y++);
|
||||
term.set_dim(true);
|
||||
term.print("Press any key to exit...");
|
||||
term.reset_attributes();
|
||||
|
||||
term.refresh();
|
||||
}
|
||||
|
||||
int main() {
|
||||
Terminal term;
|
||||
|
||||
if (!term.init()) {
|
||||
std::cerr << "Failed to initialize terminal" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
test_true_color(term);
|
||||
|
||||
// 等待按键
|
||||
term.get_key(-1);
|
||||
|
||||
term.cleanup();
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
term.cleanup();
|
||||
std::cerr << "Error: " << e.what() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue