mirror of
https://github.com/m1ngsama/TUT.git
synced 2025-12-26 20:14:10 +00:00
Compare commits
No commits in common. "main" and "v2025.12.08-354133b" have entirely different histories.
main
...
v2025.12.0
46 changed files with 1731 additions and 6855 deletions
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -1,21 +1,6 @@
|
||||||
# Build artifacts
|
|
||||||
build/
|
build/
|
||||||
*.o
|
*.o
|
||||||
*.a
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Executables
|
|
||||||
tut
|
tut
|
||||||
nbtca_tui
|
|
||||||
|
|
||||||
# CMake artifacts
|
|
||||||
CMakeCache.txt
|
|
||||||
CMakeFiles/
|
|
||||||
cmake_install.cmake
|
|
||||||
install_manifest.txt
|
|
||||||
|
|
||||||
# Editor/IDE
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
|
||||||
120
CMakeLists.txt
120
CMakeLists.txt
|
|
@ -1,130 +1,30 @@
|
||||||
cmake_minimum_required(VERSION 3.15)
|
cmake_minimum_required(VERSION 3.15)
|
||||||
project(TUT_v2 VERSION 2.0.0 LANGUAGES CXX)
|
project(TUT LANGUAGES CXX)
|
||||||
|
|
||||||
# C++17标准
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
|
||||||
|
|
||||||
# 编译选项
|
# 优先使用带宽字符支持的 ncursesw
|
||||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
set(CURSES_NEED_WIDE TRUE)
|
||||||
|
|
||||||
# macOS: Use Homebrew ncurses if available
|
# macOS: Homebrew ncurses 路径
|
||||||
if(APPLE)
|
if(APPLE)
|
||||||
set(CMAKE_PREFIX_PATH "/opt/homebrew/opt/ncurses" ${CMAKE_PREFIX_PATH})
|
set(CMAKE_PREFIX_PATH "/opt/homebrew/opt/ncurses" ${CMAKE_PREFIX_PATH})
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# 查找依赖库
|
|
||||||
find_package(CURL REQUIRED)
|
|
||||||
find_package(Curses REQUIRED)
|
find_package(Curses REQUIRED)
|
||||||
find_package(PkgConfig REQUIRED)
|
find_package(CURL REQUIRED)
|
||||||
pkg_check_modules(GUMBO REQUIRED gumbo)
|
|
||||||
|
|
||||||
# 包含目录
|
|
||||||
include_directories(
|
|
||||||
${CMAKE_SOURCE_DIR}/src
|
|
||||||
${CMAKE_SOURCE_DIR}/src/core
|
|
||||||
${CMAKE_SOURCE_DIR}/src/render
|
|
||||||
${CMAKE_SOURCE_DIR}/src/layout
|
|
||||||
${CMAKE_SOURCE_DIR}/src/parser
|
|
||||||
${CMAKE_SOURCE_DIR}/src/network
|
|
||||||
${CMAKE_SOURCE_DIR}/src/input
|
|
||||||
${CMAKE_SOURCE_DIR}/src/utils
|
|
||||||
${CURL_INCLUDE_DIRS}
|
|
||||||
${CURSES_INCLUDE_DIRS}
|
|
||||||
${GUMBO_INCLUDE_DIRS}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==================== Terminal 测试程序 ====================
|
|
||||||
|
|
||||||
add_executable(test_terminal
|
|
||||||
src/render/terminal.cpp
|
|
||||||
tests/test_terminal.cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(test_terminal
|
|
||||||
${CURSES_LIBRARIES}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==================== Renderer 测试程序 ====================
|
|
||||||
|
|
||||||
add_executable(test_renderer
|
|
||||||
src/render/terminal.cpp
|
|
||||||
src/render/renderer.cpp
|
|
||||||
src/utils/unicode.cpp
|
|
||||||
tests/test_renderer.cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(test_renderer
|
|
||||||
${CURSES_LIBRARIES}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==================== Layout 测试程序 ====================
|
|
||||||
|
|
||||||
add_executable(test_layout
|
|
||||||
src/render/terminal.cpp
|
|
||||||
src/render/renderer.cpp
|
|
||||||
src/render/layout.cpp
|
|
||||||
src/render/image.cpp
|
|
||||||
src/utils/unicode.cpp
|
|
||||||
src/dom_tree.cpp
|
|
||||||
src/html_parser.cpp
|
|
||||||
tests/test_layout.cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_directories(test_layout PRIVATE
|
|
||||||
${GUMBO_LIBRARY_DIRS}
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(test_layout
|
|
||||||
${CURSES_LIBRARIES}
|
|
||||||
${GUMBO_LIBRARIES}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==================== TUT 2.0 主程序 ====================
|
|
||||||
|
|
||||||
add_executable(tut2
|
|
||||||
src/main_v2.cpp
|
|
||||||
src/browser_v2.cpp
|
|
||||||
src/http_client.cpp
|
|
||||||
src/input_handler.cpp
|
|
||||||
src/render/terminal.cpp
|
|
||||||
src/render/renderer.cpp
|
|
||||||
src/render/layout.cpp
|
|
||||||
src/render/image.cpp
|
|
||||||
src/utils/unicode.cpp
|
|
||||||
src/dom_tree.cpp
|
|
||||||
src/html_parser.cpp
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_directories(tut2 PRIVATE
|
|
||||||
${GUMBO_LIBRARY_DIRS}
|
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(tut2
|
|
||||||
${CURSES_LIBRARIES}
|
|
||||||
CURL::libcurl
|
|
||||||
${GUMBO_LIBRARIES}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==================== 旧版主程序 (向后兼容) ====================
|
|
||||||
|
|
||||||
add_executable(tut
|
add_executable(tut
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/browser.cpp
|
|
||||||
src/http_client.cpp
|
src/http_client.cpp
|
||||||
|
src/html_parser.cpp
|
||||||
src/text_renderer.cpp
|
src/text_renderer.cpp
|
||||||
src/input_handler.cpp
|
src/input_handler.cpp
|
||||||
src/dom_tree.cpp
|
src/browser.cpp
|
||||||
src/html_parser.cpp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_directories(tut PRIVATE
|
target_include_directories(tut PRIVATE ${CURSES_INCLUDE_DIR})
|
||||||
${GUMBO_LIBRARY_DIRS}
|
target_link_libraries(tut PRIVATE ${CURSES_LIBRARIES} CURL::libcurl)
|
||||||
)
|
|
||||||
|
|
||||||
target_link_libraries(tut
|
|
||||||
${CURSES_LIBRARIES}
|
|
||||||
CURL::libcurl
|
|
||||||
${GUMBO_LIBRARIES}
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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!
|
|
||||||
54
Makefile
54
Makefile
|
|
@ -1,28 +1,44 @@
|
||||||
# Simple Makefile wrapper for CMake build system
|
# Makefile for TUT Browser
|
||||||
# Follows Unix convention: simple interface to underlying build
|
|
||||||
|
|
||||||
BUILD_DIR = build
|
CXX = clang++
|
||||||
|
CXXFLAGS = -std=c++17 -Wall -Wextra -O2
|
||||||
|
LDFLAGS = -lncurses -lcurl
|
||||||
|
|
||||||
|
# 源文件
|
||||||
|
SOURCES = src/main.cpp \
|
||||||
|
src/http_client.cpp \
|
||||||
|
src/html_parser.cpp \
|
||||||
|
src/text_renderer.cpp \
|
||||||
|
src/input_handler.cpp \
|
||||||
|
src/browser.cpp
|
||||||
|
|
||||||
|
# 目标文件
|
||||||
|
OBJECTS = $(SOURCES:.cpp=.o)
|
||||||
|
|
||||||
|
# 可执行文件
|
||||||
TARGET = tut
|
TARGET = tut
|
||||||
|
|
||||||
.PHONY: all clean install test help
|
# 默认目标
|
||||||
|
all: $(TARGET)
|
||||||
|
|
||||||
all:
|
# 链接
|
||||||
@mkdir -p $(BUILD_DIR)
|
$(TARGET): $(OBJECTS)
|
||||||
@cd $(BUILD_DIR) && cmake .. && cmake --build .
|
$(CXX) $(OBJECTS) $(LDFLAGS) -o $(TARGET)
|
||||||
@cp $(BUILD_DIR)/$(TARGET) .
|
|
||||||
|
|
||||||
|
# 编译
|
||||||
|
%.o: %.cpp
|
||||||
|
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||||
|
|
||||||
|
# 清理
|
||||||
clean:
|
clean:
|
||||||
@rm -rf $(BUILD_DIR) $(TARGET)
|
rm -f $(OBJECTS) $(TARGET)
|
||||||
|
|
||||||
install: all
|
# 运行
|
||||||
@install -m 755 $(TARGET) /usr/local/bin/
|
run: $(TARGET)
|
||||||
|
./$(TARGET)
|
||||||
|
|
||||||
test: all
|
# 安装
|
||||||
@./$(TARGET) https://example.com
|
install: $(TARGET)
|
||||||
|
install -m 755 $(TARGET) /usr/local/bin/
|
||||||
|
|
||||||
help:
|
.PHONY: all clean run install
|
||||||
@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"
|
|
||||||
|
|
|
||||||
144
NEXT_STEPS.md
144
NEXT_STEPS.md
|
|
@ -1,144 +0,0 @@
|
||||||
# TUT 2.0 - 下次继续从这里开始
|
|
||||||
|
|
||||||
## 当前位置
|
|
||||||
- **阶段**: Phase 4 - 图片支持 (基础完成)
|
|
||||||
- **进度**: 占位符显示已完成,ASCII Art 渲染框架就绪
|
|
||||||
- **最后提交**: `d80d0a1 feat: Implement TUT 2.0 with new rendering architecture`
|
|
||||||
- **待推送**: 本地有 3 个提交未推送到 origin/main
|
|
||||||
|
|
||||||
## 立即可做的事
|
|
||||||
|
|
||||||
### 1. 推送代码到远程
|
|
||||||
```bash
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 启用完整图片支持 (PNG/JPEG)
|
|
||||||
```bash
|
|
||||||
# 下载 stb_image.h
|
|
||||||
curl -L https://raw.githubusercontent.com/nothings/stb/master/stb_image.h \
|
|
||||||
-o src/utils/stb_image.h
|
|
||||||
|
|
||||||
# 重新编译
|
|
||||||
cmake --build build_v2
|
|
||||||
|
|
||||||
# 编译后 ImageRenderer::load_from_memory() 将自动支持 PNG/JPEG/GIF/BMP
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 在浏览器中集成图片渲染
|
|
||||||
需要在 `browser_v2.cpp` 中:
|
|
||||||
1. 收集页面中的所有 `<img>` 标签
|
|
||||||
2. 使用 `HttpClient::fetch_binary()` 下载图片
|
|
||||||
3. 调用 `ImageRenderer::load_from_memory()` 解码
|
|
||||||
4. 调用 `ImageRenderer::render()` 生成 ASCII Art
|
|
||||||
5. 将结果插入到布局中
|
|
||||||
|
|
||||||
## 已完成的功能清单
|
|
||||||
|
|
||||||
### Phase 4 - 图片支持
|
|
||||||
- [x] `<img>` 标签解析 (src, alt, width, height)
|
|
||||||
- [x] 图片占位符显示 `[alt text]` 或 `[Image: filename]`
|
|
||||||
- [x] `BinaryResponse` 结构体
|
|
||||||
- [x] `HttpClient::fetch_binary()` 方法
|
|
||||||
- [x] `ImageRenderer` 类框架
|
|
||||||
- [x] PPM 格式内置解码
|
|
||||||
- [ ] stb_image.h 集成 (需手动下载)
|
|
||||||
- [ ] 浏览器中的图片下载和渲染
|
|
||||||
|
|
||||||
### 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_v2.cpp/h # 新架构浏览器 (pImpl模式)
|
|
||||||
├── main_v2.cpp # tut2 入口点
|
|
||||||
├── http_client.cpp/h # HTTP 客户端 (支持二进制)
|
|
||||||
├── dom_tree.cpp/h # DOM 树
|
|
||||||
├── html_parser.cpp/h # HTML 解析
|
|
||||||
├── input_handler.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 + 图片占位符测试
|
|
||||||
```
|
|
||||||
|
|
||||||
## 构建与运行
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 构建
|
|
||||||
cmake -B build_v2 -S . -DCMAKE_BUILD_TYPE=Debug
|
|
||||||
cmake --build build_v2
|
|
||||||
|
|
||||||
# 运行
|
|
||||||
./build_v2/tut2 # 显示帮助
|
|
||||||
./build_v2/tut2 https://example.com # 打开网页
|
|
||||||
|
|
||||||
# 测试
|
|
||||||
./build_v2/test_terminal # 终端测试
|
|
||||||
./build_v2/test_renderer # 渲染测试
|
|
||||||
./build_v2/test_layout # 布局+图片测试 (按回车进入交互模式)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 快捷键
|
|
||||||
|
|
||||||
| 键 | 功能 |
|
|
||||||
|---|---|
|
|
||||||
| j/k | 上下滚动 |
|
|
||||||
| Ctrl+d/u | 翻页 |
|
|
||||||
| gg/G | 顶部/底部 |
|
|
||||||
| Tab/Shift+Tab | 切换链接 |
|
|
||||||
| Enter | 跟随链接 |
|
|
||||||
| h/l | 后退/前进 |
|
|
||||||
| / | 搜索 |
|
|
||||||
| n/N | 下一个/上一个匹配 |
|
|
||||||
| r | 刷新 (跳过缓存) |
|
|
||||||
| :o URL | 打开URL |
|
|
||||||
| :q | 退出 |
|
|
||||||
| ? | 帮助 |
|
|
||||||
|
|
||||||
## 下一步功能优先级
|
|
||||||
|
|
||||||
1. **完成图片 ASCII Art 渲染** - 下载 stb_image.h 并集成到浏览器
|
|
||||||
2. **书签管理** - 添加/删除书签,书签列表页面,持久化存储
|
|
||||||
3. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求
|
|
||||||
4. **更多表单交互** - 文本输入编辑,下拉选择
|
|
||||||
|
|
||||||
## 恢复对话时说
|
|
||||||
|
|
||||||
> "继续TUT 2.0开发"
|
|
||||||
|
|
||||||
---
|
|
||||||
更新时间: 2025-12-26 15:00
|
|
||||||
20
README.md
20
README.md
|
|
@ -88,24 +88,6 @@ KEYBINDINGS
|
||||||
**N**
|
**N**
|
||||||
Jump to previous search match.
|
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
|
### Commands
|
||||||
|
|
||||||
Press **:** to enter command mode. Available commands:
|
Press **:** to enter command mode. Available commands:
|
||||||
|
|
@ -155,6 +137,8 @@ If you only see JavaScript code or empty div elements, it will not.
|
||||||
Additionally:
|
Additionally:
|
||||||
- No image display
|
- No image display
|
||||||
- No CSS layout support
|
- No CSS layout support
|
||||||
|
- No form submission
|
||||||
|
- No cookie or session management
|
||||||
- No AJAX or dynamic content loading
|
- No AJAX or dynamic content loading
|
||||||
|
|
||||||
EXAMPLES
|
EXAMPLES
|
||||||
|
|
|
||||||
625
src/browser.cpp
625
src/browser.cpp
|
|
@ -1,12 +1,8 @@
|
||||||
#include "browser.h"
|
#include "browser.h"
|
||||||
#include "dom_tree.h"
|
|
||||||
#include <curses.h>
|
#include <curses.h>
|
||||||
#include <clocale>
|
#include <clocale>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <map>
|
|
||||||
#include <cctype>
|
|
||||||
#include <cstdio>
|
|
||||||
|
|
||||||
class Browser::Impl {
|
class Browser::Impl {
|
||||||
public:
|
public:
|
||||||
|
|
@ -15,13 +11,14 @@ public:
|
||||||
TextRenderer renderer;
|
TextRenderer renderer;
|
||||||
InputHandler input_handler;
|
InputHandler input_handler;
|
||||||
|
|
||||||
DocumentTree current_tree;
|
ParsedDocument current_doc;
|
||||||
std::vector<RenderedLine> rendered_lines;
|
std::vector<RenderedLine> rendered_lines;
|
||||||
std::string current_url;
|
std::string current_url;
|
||||||
std::vector<std::string> history;
|
std::vector<std::string> history;
|
||||||
int history_pos = -1;
|
int history_pos = -1;
|
||||||
|
|
||||||
int scroll_pos = 0;
|
int scroll_pos = 0;
|
||||||
|
int current_link = -1;
|
||||||
std::string status_message;
|
std::string status_message;
|
||||||
std::string search_term;
|
std::string search_term;
|
||||||
std::vector<int> search_results;
|
std::vector<int> search_results;
|
||||||
|
|
@ -29,19 +26,6 @@ public:
|
||||||
int screen_height = 0;
|
int screen_height = 0;
|
||||||
int screen_width = 0;
|
int screen_width = 0;
|
||||||
|
|
||||||
// Marks support
|
|
||||||
std::map<char, int> marks;
|
|
||||||
|
|
||||||
// Interactive elements (Links + Form Fields)
|
|
||||||
struct InteractiveElement {
|
|
||||||
int link_index = -1;
|
|
||||||
int field_index = -1;
|
|
||||||
int line_index = -1;
|
|
||||||
InteractiveRange range;
|
|
||||||
};
|
|
||||||
std::vector<InteractiveElement> interactive_elements;
|
|
||||||
int current_element_index = -1;
|
|
||||||
|
|
||||||
void init_screen() {
|
void init_screen() {
|
||||||
setlocale(LC_ALL, "");
|
setlocale(LC_ALL, "");
|
||||||
initscr();
|
initscr();
|
||||||
|
|
@ -51,11 +35,6 @@ public:
|
||||||
keypad(stdscr, TRUE);
|
keypad(stdscr, TRUE);
|
||||||
curs_set(0);
|
curs_set(0);
|
||||||
timeout(0);
|
timeout(0);
|
||||||
|
|
||||||
// Enable mouse support
|
|
||||||
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL);
|
|
||||||
mouseinterval(0); // No click delay
|
|
||||||
|
|
||||||
getmaxyx(stdscr, screen_height, screen_width);
|
getmaxyx(stdscr, screen_height, screen_width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,25 +42,6 @@ public:
|
||||||
endwin();
|
endwin();
|
||||||
}
|
}
|
||||||
|
|
||||||
void build_interactive_list() {
|
|
||||||
interactive_elements.clear();
|
|
||||||
for (size_t i = 0; i < rendered_lines.size(); ++i) {
|
|
||||||
for (const auto& range : rendered_lines[i].interactive_ranges) {
|
|
||||||
InteractiveElement el;
|
|
||||||
el.link_index = range.link_index;
|
|
||||||
el.field_index = range.field_index;
|
|
||||||
el.line_index = static_cast<int>(i);
|
|
||||||
el.range = range;
|
|
||||||
interactive_elements.push_back(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset or adjust current_element_index
|
|
||||||
if (current_element_index >= static_cast<int>(interactive_elements.size())) {
|
|
||||||
current_element_index = interactive_elements.empty() ? -1 : 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool load_page(const std::string& url) {
|
bool load_page(const std::string& url) {
|
||||||
status_message = "Loading " + url + "...";
|
status_message = "Loading " + url + "...";
|
||||||
draw_screen();
|
draw_screen();
|
||||||
|
|
@ -96,13 +56,11 @@ public:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
current_tree = html_parser.parse_tree(response.body, url);
|
current_doc = html_parser.parse(response.body, url);
|
||||||
rendered_lines = renderer.render_tree(current_tree, screen_width);
|
rendered_lines = renderer.render(current_doc, screen_width);
|
||||||
build_interactive_list();
|
|
||||||
|
|
||||||
current_url = url;
|
current_url = url;
|
||||||
scroll_pos = 0;
|
scroll_pos = 0;
|
||||||
current_element_index = interactive_elements.empty() ? -1 : 0;
|
current_link = -1;
|
||||||
search_results.clear();
|
search_results.clear();
|
||||||
|
|
||||||
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
|
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
|
||||||
|
|
@ -111,198 +69,10 @@ public:
|
||||||
history.push_back(url);
|
history.push_back(url);
|
||||||
history_pos = history.size() - 1;
|
history_pos = history.size() - 1;
|
||||||
|
|
||||||
status_message = current_tree.title.empty() ? url : current_tree.title;
|
status_message = current_doc.title.empty() ? url : current_doc.title;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void handle_mouse(MEVENT& event) {
|
|
||||||
int visible_lines = screen_height - 2;
|
|
||||||
|
|
||||||
if (event.bstate & BUTTON4_PRESSED) {
|
|
||||||
scroll_pos = std::max(0, scroll_pos - 3);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.bstate & BUTTON5_PRESSED) {
|
|
||||||
int max_scroll = std::max(0, static_cast<int>(rendered_lines.size()) - visible_lines);
|
|
||||||
scroll_pos = std::min(max_scroll, scroll_pos + 3);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.bstate & BUTTON1_CLICKED) {
|
|
||||||
int clicked_line = event.y;
|
|
||||||
int clicked_col = event.x;
|
|
||||||
|
|
||||||
if (clicked_line >= 0 && clicked_line < visible_lines) {
|
|
||||||
int doc_line_idx = scroll_pos + clicked_line;
|
|
||||||
if (doc_line_idx < static_cast<int>(rendered_lines.size())) {
|
|
||||||
for (size_t i = 0; i < interactive_elements.size(); ++i) {
|
|
||||||
const auto& el = interactive_elements[i];
|
|
||||||
if (el.line_index == doc_line_idx &&
|
|
||||||
clicked_col >= static_cast<int>(el.range.start) &&
|
|
||||||
clicked_col < static_cast<int>(el.range.end)) {
|
|
||||||
|
|
||||||
current_element_index = i;
|
|
||||||
activate_element(i);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void activate_element(int index) {
|
|
||||||
if (index < 0 || index >= static_cast<int>(interactive_elements.size())) return;
|
|
||||||
|
|
||||||
const auto& el = interactive_elements[index];
|
|
||||||
if (el.link_index >= 0) {
|
|
||||||
if (el.link_index < static_cast<int>(current_tree.links.size())) {
|
|
||||||
load_page(current_tree.links[el.link_index].url);
|
|
||||||
}
|
|
||||||
} else if (el.field_index >= 0) {
|
|
||||||
handle_form_interaction(el.field_index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void handle_form_interaction(int field_idx) {
|
|
||||||
if (field_idx < 0 || field_idx >= static_cast<int>(current_tree.form_fields.size())) return;
|
|
||||||
|
|
||||||
DomNode* node = current_tree.form_fields[field_idx];
|
|
||||||
|
|
||||||
if (node->input_type == "checkbox" || node->input_type == "radio") {
|
|
||||||
if (node->input_type == "radio") {
|
|
||||||
// Uncheck others in same group
|
|
||||||
DomNode* form = node->parent;
|
|
||||||
// Find form parent
|
|
||||||
while (form && form->element_type != ElementType::FORM) form = form->parent;
|
|
||||||
|
|
||||||
// If found form, traverse to uncheck others with same name
|
|
||||||
// This is a complex traversal, simplified: just toggle for now or assume single radio group
|
|
||||||
node->checked = true;
|
|
||||||
} else {
|
|
||||||
node->checked = !node->checked;
|
|
||||||
}
|
|
||||||
// Re-render
|
|
||||||
rendered_lines = renderer.render_tree(current_tree, screen_width);
|
|
||||||
build_interactive_list();
|
|
||||||
} else if (node->input_type == "text" || node->input_type == "password" ||
|
|
||||||
node->input_type == "textarea" || node->input_type == "search" ||
|
|
||||||
node->input_type == "email" || node->input_type == "url") {
|
|
||||||
|
|
||||||
// Prompt user
|
|
||||||
mvprintw(screen_height - 1, 0, "Input: ");
|
|
||||||
clrtoeol();
|
|
||||||
echo();
|
|
||||||
curs_set(1);
|
|
||||||
char buffer[256];
|
|
||||||
getnstr(buffer, 255);
|
|
||||||
noecho();
|
|
||||||
curs_set(0);
|
|
||||||
|
|
||||||
node->value = buffer;
|
|
||||||
rendered_lines = renderer.render_tree(current_tree, screen_width);
|
|
||||||
build_interactive_list();
|
|
||||||
|
|
||||||
} else if (node->input_type == "submit" || node->input_type == "button") {
|
|
||||||
submit_form(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL encode helper function
|
|
||||||
std::string url_encode(const std::string& value) {
|
|
||||||
std::string result;
|
|
||||||
for (unsigned char c : value) {
|
|
||||||
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
|
|
||||||
result += c;
|
|
||||||
} else if (c == ' ') {
|
|
||||||
result += '+';
|
|
||||||
} else {
|
|
||||||
char hex[4];
|
|
||||||
snprintf(hex, sizeof(hex), "%%%02X", c);
|
|
||||||
result += hex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
void submit_form(DomNode* button) {
|
|
||||||
status_message = "Submitting form...";
|
|
||||||
|
|
||||||
// Find parent form
|
|
||||||
DomNode* form = button->parent;
|
|
||||||
while (form && form->element_type != ElementType::FORM) form = form->parent;
|
|
||||||
|
|
||||||
if (!form) {
|
|
||||||
status_message = "Error: Button not in a form";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect form data with URL encoding
|
|
||||||
std::string form_data;
|
|
||||||
for (DomNode* field : current_tree.form_fields) {
|
|
||||||
// Check if field belongs to this form
|
|
||||||
DomNode* p = field->parent;
|
|
||||||
bool is_child = false;
|
|
||||||
while(p) { if(p == form) { is_child = true; break; } p = p->parent; }
|
|
||||||
|
|
||||||
if (is_child && !field->name.empty()) {
|
|
||||||
if (!form_data.empty()) form_data += "&";
|
|
||||||
form_data += url_encode(field->name) + "=" + url_encode(field->value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string target_url = form->action;
|
|
||||||
if (target_url.empty()) target_url = current_url;
|
|
||||||
|
|
||||||
// Check form method (default to GET if not specified)
|
|
||||||
std::string method = form->method;
|
|
||||||
std::transform(method.begin(), method.end(), method.begin(), ::toupper);
|
|
||||||
|
|
||||||
if (method == "POST") {
|
|
||||||
// POST request
|
|
||||||
status_message = "Sending POST request...";
|
|
||||||
HttpResponse response = http_client.post(target_url, form_data);
|
|
||||||
|
|
||||||
if (!response.error_message.empty()) {
|
|
||||||
status_message = "Error: " + response.error_message;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.is_success()) {
|
|
||||||
status_message = "Error: HTTP " + std::to_string(response.status_code);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and render response
|
|
||||||
DocumentTree tree = html_parser.parse_tree(response.body, target_url);
|
|
||||||
current_tree = std::move(tree);
|
|
||||||
current_url = target_url;
|
|
||||||
rendered_lines = renderer.render_tree(current_tree, screen_width);
|
|
||||||
build_interactive_list();
|
|
||||||
scroll_pos = 0;
|
|
||||||
current_element_index = -1;
|
|
||||||
|
|
||||||
// Update history
|
|
||||||
if (history_pos < static_cast<int>(history.size()) - 1) {
|
|
||||||
history.erase(history.begin() + history_pos + 1, history.end());
|
|
||||||
}
|
|
||||||
history.push_back(current_url);
|
|
||||||
history_pos = history.size() - 1;
|
|
||||||
|
|
||||||
status_message = "Form submitted (POST)";
|
|
||||||
} else {
|
|
||||||
// GET request (default)
|
|
||||||
if (target_url.find('?') == std::string::npos) {
|
|
||||||
target_url += "?" + form_data;
|
|
||||||
} else {
|
|
||||||
target_url += "&" + form_data;
|
|
||||||
}
|
|
||||||
load_page(target_url);
|
|
||||||
status_message = "Form submitted (GET)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void draw_status_bar() {
|
void draw_status_bar() {
|
||||||
attron(COLOR_PAIR(COLOR_STATUS_BAR));
|
attron(COLOR_PAIR(COLOR_STATUS_BAR));
|
||||||
mvprintw(screen_height - 1, 0, "%s", std::string(screen_width, ' ').c_str());
|
mvprintw(screen_height - 1, 0, "%s", std::string(screen_width, ' ').c_str());
|
||||||
|
|
@ -310,263 +80,302 @@ public:
|
||||||
std::string mode_str;
|
std::string mode_str;
|
||||||
InputMode mode = input_handler.get_mode();
|
InputMode mode = input_handler.get_mode();
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case InputMode::NORMAL: mode_str = "NORMAL"; break;
|
case InputMode::NORMAL:
|
||||||
|
mode_str = "NORMAL";
|
||||||
|
break;
|
||||||
case InputMode::COMMAND:
|
case InputMode::COMMAND:
|
||||||
case InputMode::SEARCH: mode_str = input_handler.get_buffer(); break;
|
case InputMode::SEARCH:
|
||||||
default: mode_str = ""; break;
|
mode_str = input_handler.get_buffer();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
mode_str = "";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
mvprintw(screen_height - 1, 0, " %s", mode_str.c_str());
|
mvprintw(screen_height - 1, 0, " %s", mode_str.c_str());
|
||||||
|
|
||||||
if (mode == InputMode::NORMAL) {
|
if (!status_message.empty() && mode == InputMode::NORMAL) {
|
||||||
std::string display_msg;
|
int msg_x = (screen_width - status_message.length()) / 2;
|
||||||
|
if (msg_x < static_cast<int>(mode_str.length()) + 2) {
|
||||||
// Priority: Hovered Link URL > Status Message > Title
|
msg_x = mode_str.length() + 2;
|
||||||
if (current_element_index >= 0 &&
|
|
||||||
current_element_index < static_cast<int>(interactive_elements.size())) {
|
|
||||||
const auto& el = interactive_elements[current_element_index];
|
|
||||||
if (el.link_index >= 0 && el.link_index < static_cast<int>(current_tree.links.size())) {
|
|
||||||
display_msg = current_tree.links[el.link_index].url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (display_msg.empty()) {
|
|
||||||
display_msg = status_message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!display_msg.empty()) {
|
|
||||||
int msg_x = (screen_width - display_msg.length()) / 2;
|
|
||||||
if (msg_x < static_cast<int>(mode_str.length()) + 2) msg_x = mode_str.length() + 2;
|
|
||||||
// Truncate if too long
|
|
||||||
int max_len = screen_width - msg_x - 20; // Reserve space for position info
|
|
||||||
if (max_len > 0) {
|
|
||||||
if (display_msg.length() > static_cast<size_t>(max_len)) {
|
|
||||||
display_msg = display_msg.substr(0, max_len - 3) + "...";
|
|
||||||
}
|
|
||||||
mvprintw(screen_height - 1, msg_x, "%s", display_msg.c_str());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
mvprintw(screen_height - 1, msg_x, "%s", status_message.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
int total_lines = rendered_lines.size();
|
int total_lines = rendered_lines.size();
|
||||||
int percentage = (total_lines > 0 && scroll_pos + screen_height - 2 < total_lines) ?
|
int visible_lines = screen_height - 2;
|
||||||
(scroll_pos * 100) / total_lines : 100;
|
int percentage = 0;
|
||||||
if (total_lines == 0) percentage = 0;
|
if (total_lines > 0) {
|
||||||
|
if (scroll_pos == 0) {
|
||||||
|
percentage = 0;
|
||||||
|
} else if (scroll_pos + visible_lines >= total_lines) {
|
||||||
|
percentage = 100;
|
||||||
|
} else {
|
||||||
|
percentage = (scroll_pos * 100) / total_lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string pos_str = std::to_string(scroll_pos + 1) + "/" +
|
||||||
|
std::to_string(total_lines) + " " +
|
||||||
|
std::to_string(percentage) + "%";
|
||||||
|
|
||||||
|
if (current_link >= 0 && current_link < static_cast<int>(current_doc.links.size())) {
|
||||||
|
pos_str = "[Link " + std::to_string(current_link) + "] " + pos_str;
|
||||||
|
}
|
||||||
|
|
||||||
std::string pos_str = std::to_string(scroll_pos + 1) + "/" + std::to_string(total_lines) + " " + std::to_string(percentage) + "%";
|
|
||||||
mvprintw(screen_height - 1, screen_width - pos_str.length() - 1, "%s", pos_str.c_str());
|
mvprintw(screen_height - 1, screen_width - pos_str.length() - 1, "%s", pos_str.c_str());
|
||||||
|
|
||||||
attroff(COLOR_PAIR(COLOR_STATUS_BAR));
|
attroff(COLOR_PAIR(COLOR_STATUS_BAR));
|
||||||
}
|
}
|
||||||
|
|
||||||
int get_utf8_sequence_length(char c) {
|
|
||||||
if ((c & 0x80) == 0) return 1;
|
|
||||||
if ((c & 0xE0) == 0xC0) return 2;
|
|
||||||
if ((c & 0xF0) == 0xE0) return 3;
|
|
||||||
if ((c & 0xF8) == 0xF0) return 4;
|
|
||||||
return 1; // Fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
void draw_screen() {
|
void draw_screen() {
|
||||||
clear();
|
clear();
|
||||||
|
|
||||||
int visible_lines = screen_height - 2;
|
int visible_lines = screen_height - 2;
|
||||||
int content_lines = std::min(static_cast<int>(rendered_lines.size()) - scroll_pos, visible_lines);
|
int content_lines = std::min(static_cast<int>(rendered_lines.size()) - scroll_pos, visible_lines);
|
||||||
|
|
||||||
int cursor_y = -1;
|
|
||||||
int cursor_x = -1;
|
|
||||||
|
|
||||||
for (int i = 0; i < content_lines; ++i) {
|
for (int i = 0; i < content_lines; ++i) {
|
||||||
int line_idx = scroll_pos + i;
|
int line_idx = scroll_pos + i;
|
||||||
const auto& line = rendered_lines[line_idx];
|
const auto& line = rendered_lines[line_idx];
|
||||||
|
|
||||||
// Check if this line is in search results
|
if (line.is_link && line.link_index == current_link) {
|
||||||
bool in_search_results = !search_term.empty() &&
|
|
||||||
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end();
|
|
||||||
|
|
||||||
move(i, 0); // Move to start of line
|
|
||||||
|
|
||||||
size_t byte_idx = 0;
|
|
||||||
int current_col = 0; // Track visual column
|
|
||||||
|
|
||||||
while (byte_idx < line.text.length()) {
|
|
||||||
size_t seq_len = get_utf8_sequence_length(line.text[byte_idx]);
|
|
||||||
// Ensure we don't read past end of string (malformed utf8 protection)
|
|
||||||
if (byte_idx + seq_len > line.text.length()) {
|
|
||||||
seq_len = line.text.length() - byte_idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool is_active = false;
|
|
||||||
bool is_interactive = false;
|
|
||||||
|
|
||||||
// Check if current byte position falls within an interactive range
|
|
||||||
for (const auto& range : line.interactive_ranges) {
|
|
||||||
if (byte_idx >= range.start && byte_idx < range.end) {
|
|
||||||
is_interactive = true;
|
|
||||||
// Check if this is the currently selected element
|
|
||||||
if (current_element_index >= 0 &&
|
|
||||||
current_element_index < static_cast<int>(interactive_elements.size())) {
|
|
||||||
const auto& el = interactive_elements[current_element_index];
|
|
||||||
if (el.line_index == line_idx &&
|
|
||||||
el.range.start == range.start &&
|
|
||||||
el.range.end == range.end) {
|
|
||||||
is_active = true;
|
|
||||||
// Capture cursor position for the START of the active element
|
|
||||||
if (byte_idx == range.start && cursor_y == -1) {
|
|
||||||
cursor_y = i;
|
|
||||||
cursor_x = current_col;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply attributes
|
|
||||||
if (is_active) {
|
|
||||||
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||||
} else if (is_interactive) {
|
|
||||||
attron(COLOR_PAIR(COLOR_LINK));
|
|
||||||
attron(A_UNDERLINE);
|
|
||||||
} else {
|
} else {
|
||||||
attron(COLOR_PAIR(line.color_pair));
|
attron(COLOR_PAIR(line.color_pair));
|
||||||
if (line.is_bold) attron(A_BOLD);
|
if (line.is_bold) {
|
||||||
|
attron(A_BOLD);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_search_results) attron(A_REVERSE);
|
if (!search_term.empty() &&
|
||||||
|
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) {
|
||||||
|
attron(A_REVERSE);
|
||||||
|
}
|
||||||
|
|
||||||
// Print the UTF-8 sequence
|
mvprintw(i, 0, "%s", line.text.c_str());
|
||||||
addnstr(line.text.c_str() + byte_idx, seq_len);
|
|
||||||
|
|
||||||
// Approximate column width update (simple)
|
if (!search_term.empty() &&
|
||||||
// For proper handling, we should use wcwidth, but for now assuming 1 or 2 based on seq_len is "okay" approximation for cursor placement
|
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) {
|
||||||
// actually addnstr advances cursor, getyx is better?
|
attroff(A_REVERSE);
|
||||||
// But we are in a loop.
|
}
|
||||||
int unused_y, x;
|
|
||||||
getyx(stdscr, unused_y, x);
|
|
||||||
(void)unused_y; // Suppress unused variable warning
|
|
||||||
current_col = x;
|
|
||||||
|
|
||||||
// Clear attributes
|
if (line.is_link && line.link_index == current_link) {
|
||||||
if (in_search_results) attroff(A_REVERSE);
|
|
||||||
|
|
||||||
if (is_active) {
|
|
||||||
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||||
} else if (is_interactive) {
|
|
||||||
attroff(A_UNDERLINE);
|
|
||||||
attroff(COLOR_PAIR(COLOR_LINK));
|
|
||||||
} else {
|
} else {
|
||||||
if (line.is_bold) attroff(A_BOLD);
|
if (line.is_bold) {
|
||||||
attroff(COLOR_PAIR(line.color_pair));
|
attroff(A_BOLD);
|
||||||
}
|
}
|
||||||
|
attroff(COLOR_PAIR(line.color_pair));
|
||||||
byte_idx += seq_len;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
draw_status_bar();
|
draw_status_bar();
|
||||||
|
|
||||||
// Place cursor
|
|
||||||
if (cursor_y != -1 && cursor_x != -1) {
|
|
||||||
curs_set(1);
|
|
||||||
move(cursor_y, cursor_x);
|
|
||||||
} else {
|
|
||||||
curs_set(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void handle_action(const InputResult& result) {
|
void handle_action(const InputResult& result) {
|
||||||
int visible_lines = screen_height - 2;
|
int visible_lines = screen_height - 2;
|
||||||
int max_scroll = std::max(0, static_cast<int>(rendered_lines.size()) - visible_lines);
|
int max_scroll = std::max(0, static_cast<int>(rendered_lines.size()) - visible_lines);
|
||||||
|
|
||||||
int count = result.has_count ? result.count : 1;
|
int count = result.has_count ? result.count : 1;
|
||||||
|
|
||||||
switch (result.action) {
|
switch (result.action) {
|
||||||
case Action::SCROLL_UP: scroll_pos = std::max(0, scroll_pos - count); break;
|
case Action::SCROLL_UP:
|
||||||
case Action::SCROLL_DOWN: scroll_pos = std::min(max_scroll, scroll_pos + count); break;
|
scroll_pos = std::max(0, scroll_pos - count);
|
||||||
case Action::SCROLL_PAGE_UP: scroll_pos = std::max(0, scroll_pos - visible_lines); break;
|
break;
|
||||||
case Action::SCROLL_PAGE_DOWN: scroll_pos = std::min(max_scroll, scroll_pos + visible_lines); break;
|
|
||||||
case Action::GOTO_TOP: scroll_pos = 0; break;
|
case Action::SCROLL_DOWN:
|
||||||
case Action::GOTO_BOTTOM: scroll_pos = max_scroll; break;
|
scroll_pos = std::min(max_scroll, scroll_pos + count);
|
||||||
case Action::GOTO_LINE: if (result.number > 0) scroll_pos = std::min(result.number - 1, max_scroll); break;
|
break;
|
||||||
|
|
||||||
|
case Action::SCROLL_PAGE_UP:
|
||||||
|
scroll_pos = std::max(0, scroll_pos - visible_lines);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Action::SCROLL_PAGE_DOWN:
|
||||||
|
scroll_pos = std::min(max_scroll, scroll_pos + visible_lines);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Action::GOTO_TOP:
|
||||||
|
scroll_pos = 0;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Action::GOTO_BOTTOM:
|
||||||
|
scroll_pos = max_scroll;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Action::GOTO_LINE:
|
||||||
|
if (result.number > 0 && result.number <= static_cast<int>(rendered_lines.size())) {
|
||||||
|
scroll_pos = std::min(result.number - 1, max_scroll);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case Action::NEXT_LINK:
|
case Action::NEXT_LINK:
|
||||||
if (!interactive_elements.empty()) {
|
if (!current_doc.links.empty()) {
|
||||||
current_element_index = (current_element_index + 1) % interactive_elements.size();
|
current_link = (current_link + 1) % current_doc.links.size();
|
||||||
scroll_to_element(current_element_index);
|
scroll_to_link(current_link);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Action::PREV_LINK:
|
case Action::PREV_LINK:
|
||||||
if (!interactive_elements.empty()) {
|
if (!current_doc.links.empty()) {
|
||||||
current_element_index = (current_element_index - 1 + interactive_elements.size()) % interactive_elements.size();
|
current_link = (current_link - 1 + current_doc.links.size()) % current_doc.links.size();
|
||||||
scroll_to_element(current_element_index);
|
scroll_to_link(current_link);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Action::FOLLOW_LINK:
|
case Action::FOLLOW_LINK:
|
||||||
activate_element(current_element_index);
|
if (current_link >= 0 && current_link < static_cast<int>(current_doc.links.size())) {
|
||||||
|
load_page(current_doc.links[current_link].url);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Action::GO_BACK:
|
case Action::GO_BACK:
|
||||||
if (history_pos > 0) { history_pos--; load_page(history[history_pos]); }
|
if (history_pos > 0) {
|
||||||
|
history_pos--;
|
||||||
|
load_page(history[history_pos]);
|
||||||
|
} else {
|
||||||
|
status_message = "No previous page";
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Action::GO_FORWARD:
|
case Action::GO_FORWARD:
|
||||||
if (history_pos < static_cast<int>(history.size()) - 1) { history_pos++; load_page(history[history_pos]); }
|
if (history_pos < static_cast<int>(history.size()) - 1) {
|
||||||
|
history_pos++;
|
||||||
|
load_page(history[history_pos]);
|
||||||
|
} else {
|
||||||
|
status_message = "No next page";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Action::OPEN_URL:
|
||||||
|
if (!result.text.empty()) {
|
||||||
|
load_page(result.text);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Action::REFRESH:
|
||||||
|
if (!current_url.empty()) {
|
||||||
|
load_page(current_url);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case Action::OPEN_URL: if (!result.text.empty()) load_page(result.text); break;
|
|
||||||
case Action::REFRESH: if (!current_url.empty()) load_page(current_url); break;
|
|
||||||
|
|
||||||
case Action::SEARCH_FORWARD:
|
case Action::SEARCH_FORWARD:
|
||||||
search_term = result.text;
|
search_term = result.text;
|
||||||
search_results.clear();
|
search_results.clear();
|
||||||
for (size_t i = 0; i < rendered_lines.size(); ++i) {
|
for (size_t i = 0; i < rendered_lines.size(); ++i) {
|
||||||
if (rendered_lines[i].text.find(search_term) != std::string::npos) search_results.push_back(i);
|
if (rendered_lines[i].text.find(search_term) != std::string::npos) {
|
||||||
|
search_results.push_back(i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!search_results.empty()) {
|
if (!search_results.empty()) {
|
||||||
scroll_pos = search_results[0];
|
scroll_pos = search_results[0];
|
||||||
status_message = "Found " + std::to_string(search_results.size()) + " matches";
|
status_message = "Found " + std::to_string(search_results.size()) + " matches";
|
||||||
} else status_message = "Pattern not found";
|
} else {
|
||||||
|
status_message = "Pattern not found: " + search_term;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Action::SEARCH_NEXT:
|
case Action::SEARCH_NEXT:
|
||||||
if (!search_results.empty()) {
|
if (!search_results.empty()) {
|
||||||
auto it = std::upper_bound(search_results.begin(), search_results.end(), scroll_pos);
|
auto it = std::upper_bound(search_results.begin(), search_results.end(), scroll_pos);
|
||||||
scroll_pos = (it != search_results.end()) ? *it : search_results[0];
|
if (it != search_results.end()) {
|
||||||
|
scroll_pos = *it;
|
||||||
|
} else {
|
||||||
|
scroll_pos = search_results[0];
|
||||||
|
status_message = "Search wrapped to top";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Action::SEARCH_PREV:
|
case Action::SEARCH_PREV:
|
||||||
if (!search_results.empty()) {
|
if (!search_results.empty()) {
|
||||||
auto it = std::lower_bound(search_results.begin(), search_results.end(), scroll_pos);
|
auto it = std::lower_bound(search_results.begin(), search_results.end(), scroll_pos);
|
||||||
scroll_pos = (it != search_results.begin()) ? *(--it) : search_results.back();
|
if (it != search_results.begin()) {
|
||||||
|
scroll_pos = *(--it);
|
||||||
|
} else {
|
||||||
|
scroll_pos = search_results.back();
|
||||||
|
status_message = "Search wrapped to bottom";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Action::HELP: show_help(); break;
|
case Action::HELP:
|
||||||
case Action::QUIT: break; // Handled in browser.run
|
show_help();
|
||||||
default: break;
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void scroll_to_element(int index) {
|
void scroll_to_link(int link_idx) {
|
||||||
if (index < 0 || index >= static_cast<int>(interactive_elements.size())) return;
|
for (size_t i = 0; i < rendered_lines.size(); ++i) {
|
||||||
|
if (rendered_lines[i].is_link && rendered_lines[i].link_index == link_idx) {
|
||||||
int line_idx = interactive_elements[index].line_index;
|
|
||||||
int visible_lines = screen_height - 2;
|
int visible_lines = screen_height - 2;
|
||||||
|
if (static_cast<int>(i) < scroll_pos || static_cast<int>(i) >= scroll_pos + visible_lines) {
|
||||||
if (line_idx < scroll_pos || line_idx >= scroll_pos + visible_lines) {
|
scroll_pos = std::max(0, static_cast<int>(i) - visible_lines / 2);
|
||||||
scroll_pos = std::max(0, line_idx - visible_lines / 2);
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void show_help() {
|
void show_help() {
|
||||||
// Updated help text would go here
|
|
||||||
std::ostringstream help_html;
|
std::ostringstream help_html;
|
||||||
help_html << "<html><body><h1>Help</h1><p>Use Tab to navigate links and form fields.</p><p>Enter to activate/edit.</p></body></html>";
|
help_html << "<html><head><title>TUT Browser Help</title></head><body>"
|
||||||
current_tree = html_parser.parse_tree(help_html.str(), "help://");
|
<< "<h1>TUT Browser - Vim-style Terminal Browser</h1>"
|
||||||
rendered_lines = renderer.render_tree(current_tree, screen_width);
|
<< "<h2>Navigation</h2>"
|
||||||
build_interactive_list();
|
<< "<p>j/k or ↓/↑: Scroll down/up</p>"
|
||||||
|
<< "<p>Ctrl-D or Space: Scroll page down</p>"
|
||||||
|
<< "<p>Ctrl-U or b: Scroll page up</p>"
|
||||||
|
<< "<p>gg: Go to top</p>"
|
||||||
|
<< "<p>G: Go to bottom</p>"
|
||||||
|
<< "<p>[number]G: Go to line number</p>"
|
||||||
|
<< "<h2>Links</h2>"
|
||||||
|
<< "<p>Tab: Next link</p>"
|
||||||
|
<< "<p>Shift-Tab or T: Previous link</p>"
|
||||||
|
<< "<p>Enter: Follow link</p>"
|
||||||
|
<< "<p>h: Go back</p>"
|
||||||
|
<< "<p>l: Go forward</p>"
|
||||||
|
<< "<h2>Search</h2>"
|
||||||
|
<< "<p>/: Start search</p>"
|
||||||
|
<< "<p>n: Next match</p>"
|
||||||
|
<< "<p>N: Previous match</p>"
|
||||||
|
<< "<h2>Commands</h2>"
|
||||||
|
<< "<p>:q or :quit - Quit browser</p>"
|
||||||
|
<< "<p>:o URL or :open URL - Open URL</p>"
|
||||||
|
<< "<p>:r or :refresh - Refresh page</p>"
|
||||||
|
<< "<p>:h or :help - Show this help</p>"
|
||||||
|
<< "<p>:[number] - Go to line number</p>"
|
||||||
|
<< "<h2>Other</h2>"
|
||||||
|
<< "<p>r: Refresh current page</p>"
|
||||||
|
<< "<p>q: Quit browser</p>"
|
||||||
|
<< "<p>?: Show help</p>"
|
||||||
|
<< "<h2>Important Limitations</h2>"
|
||||||
|
<< "<p><strong>JavaScript/SPA Websites:</strong> This browser cannot execute JavaScript. "
|
||||||
|
<< "Single Page Applications (SPAs) built with React, Vue, Angular, etc. will not work properly "
|
||||||
|
<< "as they render content dynamically with JavaScript.</p>"
|
||||||
|
<< "<p><strong>Works best with:</strong></p>"
|
||||||
|
<< "<ul>"
|
||||||
|
<< "<li>Static HTML websites</li>"
|
||||||
|
<< "<li>Server-side rendered pages</li>"
|
||||||
|
<< "<li>Documentation sites</li>"
|
||||||
|
<< "<li>News sites with HTML content</li>"
|
||||||
|
<< "<li>Blogs with traditional HTML</li>"
|
||||||
|
<< "</ul>"
|
||||||
|
<< "<p><strong>Example sites that work well:</strong></p>"
|
||||||
|
<< "<p>- https://example.com</p>"
|
||||||
|
<< "<p>- https://en.wikipedia.org</p>"
|
||||||
|
<< "<p>- Text-based news sites</p>"
|
||||||
|
<< "<p><strong>For JavaScript-heavy sites:</strong> You may need to find alternative URLs "
|
||||||
|
<< "that provide the same content in plain HTML format.</p>"
|
||||||
|
<< "</body></html>";
|
||||||
|
|
||||||
|
current_doc = html_parser.parse(help_html.str(), "help://");
|
||||||
|
rendered_lines = renderer.render(current_doc, screen_width);
|
||||||
scroll_pos = 0;
|
scroll_pos = 0;
|
||||||
current_element_index = -1;
|
current_link = -1;
|
||||||
|
status_message = "Help - Press q to return";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -581,8 +390,11 @@ Browser::~Browser() = default;
|
||||||
void Browser::run(const std::string& initial_url) {
|
void Browser::run(const std::string& initial_url) {
|
||||||
pImpl->init_screen();
|
pImpl->init_screen();
|
||||||
|
|
||||||
if (!initial_url.empty()) load_url(initial_url);
|
if (!initial_url.empty()) {
|
||||||
else pImpl->show_help();
|
load_url(initial_url);
|
||||||
|
} else {
|
||||||
|
pImpl->show_help();
|
||||||
|
}
|
||||||
|
|
||||||
bool running = true;
|
bool running = true;
|
||||||
while (running) {
|
while (running) {
|
||||||
|
|
@ -590,17 +402,18 @@ void Browser::run(const std::string& initial_url) {
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
int ch = getch();
|
int ch = getch();
|
||||||
if (ch == ERR) { napms(50); continue; }
|
if (ch == ERR) {
|
||||||
|
napms(50);
|
||||||
if (ch == KEY_MOUSE) {
|
|
||||||
MEVENT event;
|
|
||||||
if (getmouse(&event) == OK) pImpl->handle_mouse(event);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto result = pImpl->input_handler.handle_key(ch);
|
auto result = pImpl->input_handler.handle_key(ch);
|
||||||
if (result.action == Action::QUIT) running = false;
|
|
||||||
else if (result.action != Action::NONE) pImpl->handle_action(result);
|
if (result.action == Action::QUIT) {
|
||||||
|
running = false;
|
||||||
|
} else if (result.action != Action::NONE) {
|
||||||
|
pImpl->handle_action(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pImpl->cleanup_screen();
|
pImpl->cleanup_screen();
|
||||||
|
|
|
||||||
|
|
@ -1,630 +0,0 @@
|
||||||
#include "browser_v2.h"
|
|
||||||
#include "dom_tree.h"
|
|
||||||
#include "render/colors.h"
|
|
||||||
#include "render/decorations.h"
|
|
||||||
#include "utils/unicode.h"
|
|
||||||
#include <algorithm>
|
|
||||||
#include <sstream>
|
|
||||||
#include <map>
|
|
||||||
#include <cctype>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <chrono>
|
|
||||||
#include <ncurses.h>
|
|
||||||
|
|
||||||
using namespace tut;
|
|
||||||
|
|
||||||
// 缓存条目
|
|
||||||
struct CacheEntry {
|
|
||||||
DocumentTree tree;
|
|
||||||
std::string html;
|
|
||||||
std::chrono::steady_clock::time_point timestamp;
|
|
||||||
|
|
||||||
bool is_expired(int max_age_seconds = 300) const {
|
|
||||||
auto now = std::chrono::steady_clock::now();
|
|
||||||
auto age = std::chrono::duration_cast<std::chrono::seconds>(now - timestamp).count();
|
|
||||||
return age > max_age_seconds;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class BrowserV2::Impl {
|
|
||||||
public:
|
|
||||||
// 网络和解析
|
|
||||||
HttpClient http_client;
|
|
||||||
HtmlParser html_parser;
|
|
||||||
InputHandler input_handler;
|
|
||||||
|
|
||||||
// 新渲染系统
|
|
||||||
Terminal terminal;
|
|
||||||
std::unique_ptr<FrameBuffer> framebuffer;
|
|
||||||
std::unique_ptr<Renderer> renderer;
|
|
||||||
std::unique_ptr<LayoutEngine> layout_engine;
|
|
||||||
|
|
||||||
// 文档状态
|
|
||||||
DocumentTree current_tree;
|
|
||||||
LayoutResult current_layout;
|
|
||||||
std::string current_url;
|
|
||||||
std::vector<std::string> history;
|
|
||||||
int history_pos = -1;
|
|
||||||
|
|
||||||
// 视图状态
|
|
||||||
int scroll_pos = 0;
|
|
||||||
int active_link = -1;
|
|
||||||
int active_field = -1;
|
|
||||||
std::string status_message;
|
|
||||||
std::string search_term;
|
|
||||||
|
|
||||||
int screen_width = 0;
|
|
||||||
int screen_height = 0;
|
|
||||||
|
|
||||||
// Marks support
|
|
||||||
std::map<char, int> marks;
|
|
||||||
|
|
||||||
// 搜索相关
|
|
||||||
SearchContext search_ctx;
|
|
||||||
|
|
||||||
// 页面缓存
|
|
||||||
std::map<std::string, CacheEntry> page_cache;
|
|
||||||
static constexpr int CACHE_MAX_AGE = 300; // 5分钟缓存
|
|
||||||
static constexpr size_t CACHE_MAX_SIZE = 20; // 最多缓存20个页面
|
|
||||||
|
|
||||||
bool init_screen() {
|
|
||||||
if (!terminal.init()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
terminal.get_size(screen_width, screen_height);
|
|
||||||
terminal.use_alternate_screen(true);
|
|
||||||
terminal.hide_cursor();
|
|
||||||
|
|
||||||
// 创建渲染组件
|
|
||||||
framebuffer = std::make_unique<FrameBuffer>(screen_width, screen_height);
|
|
||||||
renderer = std::make_unique<Renderer>(terminal);
|
|
||||||
layout_engine = std::make_unique<LayoutEngine>(screen_width);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void cleanup_screen() {
|
|
||||||
terminal.show_cursor();
|
|
||||||
terminal.use_alternate_screen(false);
|
|
||||||
terminal.cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
void handle_resize() {
|
|
||||||
terminal.get_size(screen_width, screen_height);
|
|
||||||
framebuffer = std::make_unique<FrameBuffer>(screen_width, screen_height);
|
|
||||||
layout_engine->set_viewport_width(screen_width);
|
|
||||||
|
|
||||||
// 重新布局当前文档
|
|
||||||
if (current_tree.root) {
|
|
||||||
current_layout = layout_engine->layout(current_tree);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer->force_redraw();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool load_page(const std::string& url, bool force_refresh = false) {
|
|
||||||
// 检查缓存
|
|
||||||
auto cache_it = page_cache.find(url);
|
|
||||||
bool use_cache = !force_refresh && cache_it != page_cache.end() &&
|
|
||||||
!cache_it->second.is_expired(CACHE_MAX_AGE);
|
|
||||||
|
|
||||||
if (use_cache) {
|
|
||||||
status_message = "⚡ Loading from cache...";
|
|
||||||
draw_screen();
|
|
||||||
|
|
||||||
// 使用缓存的文档树
|
|
||||||
// 注意:需要重新解析因为DocumentTree包含unique_ptr
|
|
||||||
current_tree = html_parser.parse_tree(cache_it->second.html, url);
|
|
||||||
status_message = "⚡ " + (current_tree.title.empty() ? url : current_tree.title);
|
|
||||||
} else {
|
|
||||||
status_message = "⏳ Connecting to " + extract_host(url) + "...";
|
|
||||||
draw_screen();
|
|
||||||
|
|
||||||
auto response = http_client.fetch(url);
|
|
||||||
|
|
||||||
if (!response.is_success()) {
|
|
||||||
status_message = "❌ " + (response.error_message.empty() ?
|
|
||||||
"HTTP " + std::to_string(response.status_code) :
|
|
||||||
response.error_message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
status_message = "📄 Parsing HTML...";
|
|
||||||
draw_screen();
|
|
||||||
|
|
||||||
// 解析HTML
|
|
||||||
current_tree = html_parser.parse_tree(response.body, url);
|
|
||||||
|
|
||||||
// 添加到缓存
|
|
||||||
add_to_cache(url, response.body);
|
|
||||||
|
|
||||||
status_message = current_tree.title.empty() ? url : current_tree.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 布局计算
|
|
||||||
current_layout = layout_engine->layout(current_tree);
|
|
||||||
|
|
||||||
current_url = url;
|
|
||||||
scroll_pos = 0;
|
|
||||||
active_link = current_tree.links.empty() ? -1 : 0;
|
|
||||||
active_field = current_tree.form_fields.empty() ? -1 : 0;
|
|
||||||
search_ctx = SearchContext(); // 清除搜索状态
|
|
||||||
search_term.clear();
|
|
||||||
|
|
||||||
// 更新历史(仅在非刷新时)
|
|
||||||
if (!force_refresh) {
|
|
||||||
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
|
|
||||||
history.erase(history.begin() + history_pos + 1, history.end());
|
|
||||||
}
|
|
||||||
history.push_back(url);
|
|
||||||
history_pos = history.size() - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void add_to_cache(const std::string& url, const std::string& html) {
|
|
||||||
// 限制缓存大小
|
|
||||||
if (page_cache.size() >= CACHE_MAX_SIZE) {
|
|
||||||
// 移除最老的缓存条目
|
|
||||||
auto oldest = page_cache.begin();
|
|
||||||
for (auto it = page_cache.begin(); it != page_cache.end(); ++it) {
|
|
||||||
if (it->second.timestamp < oldest->second.timestamp) {
|
|
||||||
oldest = it;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
page_cache.erase(oldest);
|
|
||||||
}
|
|
||||||
|
|
||||||
CacheEntry entry;
|
|
||||||
entry.html = html;
|
|
||||||
entry.timestamp = std::chrono::steady_clock::now();
|
|
||||||
page_cache[url] = std::move(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从URL中提取主机名
|
|
||||||
std::string extract_host(const std::string& url) {
|
|
||||||
// 简单提取:找到://之后的部分,到第一个/为止
|
|
||||||
size_t proto_end = url.find("://");
|
|
||||||
if (proto_end == std::string::npos) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
size_t host_start = proto_end + 3;
|
|
||||||
size_t host_end = url.find('/', host_start);
|
|
||||||
if (host_end == std::string::npos) {
|
|
||||||
return url.substr(host_start);
|
|
||||||
}
|
|
||||||
return url.substr(host_start, host_end - host_start);
|
|
||||||
}
|
|
||||||
|
|
||||||
void draw_screen() {
|
|
||||||
// 清空缓冲区
|
|
||||||
framebuffer->clear_with_color(colors::BG_PRIMARY);
|
|
||||||
|
|
||||||
int content_height = screen_height - 1; // 留出状态栏
|
|
||||||
|
|
||||||
// 渲染文档内容
|
|
||||||
RenderContext render_ctx;
|
|
||||||
render_ctx.active_link = active_link;
|
|
||||||
render_ctx.active_field = active_field;
|
|
||||||
render_ctx.search = search_ctx.enabled ? &search_ctx : nullptr;
|
|
||||||
|
|
||||||
DocumentRenderer doc_renderer(*framebuffer);
|
|
||||||
doc_renderer.render(current_layout, scroll_pos, render_ctx);
|
|
||||||
|
|
||||||
// 渲染状态栏
|
|
||||||
draw_status_bar(content_height);
|
|
||||||
|
|
||||||
// 渲染到终端
|
|
||||||
renderer->render(*framebuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
void draw_status_bar(int y) {
|
|
||||||
// 状态栏背景
|
|
||||||
for (int x = 0; x < screen_width; ++x) {
|
|
||||||
framebuffer->set_cell(x, y, Cell{" ", colors::STATUSBAR_FG, colors::STATUSBAR_BG, ATTR_NONE});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 左侧: 模式
|
|
||||||
std::string mode_str;
|
|
||||||
InputMode mode = input_handler.get_mode();
|
|
||||||
switch (mode) {
|
|
||||||
case InputMode::NORMAL: mode_str = "NORMAL"; break;
|
|
||||||
case InputMode::COMMAND:
|
|
||||||
case InputMode::SEARCH: mode_str = input_handler.get_buffer(); break;
|
|
||||||
default: mode_str = ""; break;
|
|
||||||
}
|
|
||||||
framebuffer->set_text(1, y, mode_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
|
||||||
|
|
||||||
// 中间: 状态消息或链接URL
|
|
||||||
std::string display_msg;
|
|
||||||
if (mode == InputMode::NORMAL) {
|
|
||||||
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
|
|
||||||
display_msg = current_tree.links[active_link].url;
|
|
||||||
}
|
|
||||||
if (display_msg.empty()) {
|
|
||||||
display_msg = status_message;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!display_msg.empty()) {
|
|
||||||
// 截断过长的消息
|
|
||||||
size_t max_len = screen_width - mode_str.length() - 20;
|
|
||||||
if (display_msg.length() > max_len) {
|
|
||||||
display_msg = display_msg.substr(0, max_len - 3) + "...";
|
|
||||||
}
|
|
||||||
int msg_x = static_cast<int>(mode_str.length()) + 3;
|
|
||||||
framebuffer->set_text(msg_x, y, display_msg, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 右侧: 位置信息
|
|
||||||
int total_lines = current_layout.total_lines;
|
|
||||||
int visible_lines = screen_height - 1;
|
|
||||||
int percentage = (total_lines > 0 && scroll_pos + visible_lines < total_lines) ?
|
|
||||||
(scroll_pos * 100) / total_lines : 100;
|
|
||||||
if (total_lines == 0) percentage = 0;
|
|
||||||
|
|
||||||
std::string pos_str = std::to_string(scroll_pos + 1) + "/" +
|
|
||||||
std::to_string(total_lines) + " " +
|
|
||||||
std::to_string(percentage) + "%";
|
|
||||||
int pos_x = screen_width - static_cast<int>(pos_str.length()) - 1;
|
|
||||||
framebuffer->set_text(pos_x, y, pos_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
|
||||||
}
|
|
||||||
|
|
||||||
void handle_action(const InputResult& result) {
|
|
||||||
int visible_lines = screen_height - 1;
|
|
||||||
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
|
|
||||||
int count = result.has_count ? result.count : 1;
|
|
||||||
|
|
||||||
switch (result.action) {
|
|
||||||
case Action::SCROLL_UP:
|
|
||||||
scroll_pos = std::max(0, scroll_pos - count);
|
|
||||||
break;
|
|
||||||
case Action::SCROLL_DOWN:
|
|
||||||
scroll_pos = std::min(max_scroll, scroll_pos + count);
|
|
||||||
break;
|
|
||||||
case Action::SCROLL_PAGE_UP:
|
|
||||||
scroll_pos = std::max(0, scroll_pos - visible_lines);
|
|
||||||
break;
|
|
||||||
case Action::SCROLL_PAGE_DOWN:
|
|
||||||
scroll_pos = std::min(max_scroll, scroll_pos + visible_lines);
|
|
||||||
break;
|
|
||||||
case Action::GOTO_TOP:
|
|
||||||
scroll_pos = 0;
|
|
||||||
break;
|
|
||||||
case Action::GOTO_BOTTOM:
|
|
||||||
scroll_pos = max_scroll;
|
|
||||||
break;
|
|
||||||
case Action::GOTO_LINE:
|
|
||||||
if (result.number > 0) {
|
|
||||||
scroll_pos = std::min(result.number - 1, max_scroll);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::NEXT_LINK:
|
|
||||||
if (!current_tree.links.empty()) {
|
|
||||||
active_link = (active_link + 1) % current_tree.links.size();
|
|
||||||
scroll_to_link(active_link);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::PREV_LINK:
|
|
||||||
if (!current_tree.links.empty()) {
|
|
||||||
active_link = (active_link - 1 + current_tree.links.size()) % current_tree.links.size();
|
|
||||||
scroll_to_link(active_link);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::FOLLOW_LINK:
|
|
||||||
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
|
|
||||||
load_page(current_tree.links[active_link].url);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::GO_BACK:
|
|
||||||
if (history_pos > 0) {
|
|
||||||
history_pos--;
|
|
||||||
load_page(history[history_pos]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::GO_FORWARD:
|
|
||||||
if (history_pos < static_cast<int>(history.size()) - 1) {
|
|
||||||
history_pos++;
|
|
||||||
load_page(history[history_pos]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::OPEN_URL:
|
|
||||||
if (!result.text.empty()) {
|
|
||||||
load_page(result.text);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::REFRESH:
|
|
||||||
if (!current_url.empty()) {
|
|
||||||
load_page(current_url, true); // 强制刷新,跳过缓存
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::SEARCH_FORWARD: {
|
|
||||||
int count = perform_search(result.text);
|
|
||||||
if (count > 0) {
|
|
||||||
status_message = "Match 1/" + std::to_string(count);
|
|
||||||
} else if (!result.text.empty()) {
|
|
||||||
status_message = "Pattern not found: " + result.text;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case Action::SEARCH_NEXT:
|
|
||||||
search_next();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::SEARCH_PREV:
|
|
||||||
search_prev();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::HELP:
|
|
||||||
show_help();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case Action::QUIT:
|
|
||||||
break; // 在main loop处理
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行搜索,返回匹配数量
|
|
||||||
int perform_search(const std::string& term) {
|
|
||||||
search_ctx.matches.clear();
|
|
||||||
search_ctx.current_match_idx = -1;
|
|
||||||
search_ctx.enabled = false;
|
|
||||||
|
|
||||||
if (term.empty()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
search_term = term;
|
|
||||||
search_ctx.enabled = true;
|
|
||||||
|
|
||||||
// 遍历所有布局块和行,查找匹配
|
|
||||||
int doc_line = 0;
|
|
||||||
for (const auto& block : current_layout.blocks) {
|
|
||||||
// 上边距
|
|
||||||
doc_line += block.margin_top;
|
|
||||||
|
|
||||||
// 内容行
|
|
||||||
for (const auto& line : block.lines) {
|
|
||||||
// 构建整行文本用于搜索
|
|
||||||
std::string line_text;
|
|
||||||
|
|
||||||
for (const auto& span : line.spans) {
|
|
||||||
line_text += span.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索匹配(大小写不敏感)
|
|
||||||
std::string lower_line = line_text;
|
|
||||||
std::string lower_term = term;
|
|
||||||
std::transform(lower_line.begin(), lower_line.end(), lower_line.begin(), ::tolower);
|
|
||||||
std::transform(lower_term.begin(), lower_term.end(), lower_term.begin(), ::tolower);
|
|
||||||
|
|
||||||
size_t pos = 0;
|
|
||||||
while ((pos = lower_line.find(lower_term, pos)) != std::string::npos) {
|
|
||||||
SearchMatch match;
|
|
||||||
match.line = doc_line;
|
|
||||||
match.start_col = line.indent + static_cast<int>(pos);
|
|
||||||
match.length = static_cast<int>(term.length());
|
|
||||||
search_ctx.matches.push_back(match);
|
|
||||||
pos += 1; // 继续搜索下一个匹配
|
|
||||||
}
|
|
||||||
|
|
||||||
doc_line++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下边距
|
|
||||||
doc_line += block.margin_bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有匹配,跳转到第一个
|
|
||||||
if (!search_ctx.matches.empty()) {
|
|
||||||
search_ctx.current_match_idx = 0;
|
|
||||||
scroll_to_match(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return static_cast<int>(search_ctx.matches.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳转到指定匹配
|
|
||||||
void scroll_to_match(int idx) {
|
|
||||||
if (idx < 0 || idx >= static_cast<int>(search_ctx.matches.size())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
search_ctx.current_match_idx = idx;
|
|
||||||
int match_line = search_ctx.matches[idx].line;
|
|
||||||
int visible_lines = screen_height - 1;
|
|
||||||
|
|
||||||
// 确保匹配行在可见区域
|
|
||||||
if (match_line < scroll_pos) {
|
|
||||||
scroll_pos = match_line;
|
|
||||||
} else if (match_line >= scroll_pos + visible_lines) {
|
|
||||||
scroll_pos = match_line - visible_lines / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
|
|
||||||
scroll_pos = std::max(0, std::min(scroll_pos, max_scroll));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索下一个
|
|
||||||
void search_next() {
|
|
||||||
if (search_ctx.matches.empty()) {
|
|
||||||
if (!search_term.empty()) {
|
|
||||||
status_message = "Pattern not found: " + search_term;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
search_ctx.current_match_idx = (search_ctx.current_match_idx + 1) % search_ctx.matches.size();
|
|
||||||
scroll_to_match(search_ctx.current_match_idx);
|
|
||||||
status_message = "Match " + std::to_string(search_ctx.current_match_idx + 1) +
|
|
||||||
"/" + std::to_string(search_ctx.matches.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索上一个
|
|
||||||
void search_prev() {
|
|
||||||
if (search_ctx.matches.empty()) {
|
|
||||||
if (!search_term.empty()) {
|
|
||||||
status_message = "Pattern not found: " + search_term;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
search_ctx.current_match_idx = (search_ctx.current_match_idx - 1 + search_ctx.matches.size()) % search_ctx.matches.size();
|
|
||||||
scroll_to_match(search_ctx.current_match_idx);
|
|
||||||
status_message = "Match " + std::to_string(search_ctx.current_match_idx + 1) +
|
|
||||||
"/" + std::to_string(search_ctx.matches.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 滚动到链接位置
|
|
||||||
void scroll_to_link(int link_idx) {
|
|
||||||
if (link_idx < 0 || link_idx >= static_cast<int>(current_layout.link_positions.size())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auto& pos = current_layout.link_positions[link_idx];
|
|
||||||
if (pos.start_line < 0) {
|
|
||||||
return; // 链接位置无效
|
|
||||||
}
|
|
||||||
|
|
||||||
int visible_lines = screen_height - 1;
|
|
||||||
int link_line = pos.start_line;
|
|
||||||
|
|
||||||
// 确保链接行在可见区域
|
|
||||||
if (link_line < scroll_pos) {
|
|
||||||
// 链接在视口上方,滚动使其出现在顶部附近
|
|
||||||
scroll_pos = std::max(0, link_line - 2);
|
|
||||||
} else if (link_line >= scroll_pos + visible_lines) {
|
|
||||||
// 链接在视口下方,滚动使其出现在中间
|
|
||||||
scroll_pos = link_line - visible_lines / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
|
|
||||||
scroll_pos = std::max(0, std::min(scroll_pos, max_scroll));
|
|
||||||
}
|
|
||||||
|
|
||||||
void show_help() {
|
|
||||||
std::string help_html = R"(
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head><title>TUT 2.0 Help</title></head>
|
|
||||||
<body>
|
|
||||||
<h1>TUT 2.0 - Terminal Browser</h1>
|
|
||||||
|
|
||||||
<h2>Navigation</h2>
|
|
||||||
<ul>
|
|
||||||
<li>j/k - Scroll down/up</li>
|
|
||||||
<li>Ctrl+d/Ctrl+u - Page down/up</li>
|
|
||||||
<li>gg - Go to top</li>
|
|
||||||
<li>G - Go to bottom</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>Links</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Tab - Next link</li>
|
|
||||||
<li>Shift+Tab - Previous link</li>
|
|
||||||
<li>Enter - Follow link</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>History</h2>
|
|
||||||
<ul>
|
|
||||||
<li>h - Go back</li>
|
|
||||||
<li>l - Go forward</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>Search</h2>
|
|
||||||
<ul>
|
|
||||||
<li>/ - Search forward</li>
|
|
||||||
<li>n - Next match</li>
|
|
||||||
<li>N - Previous match</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>Commands</h2>
|
|
||||||
<ul>
|
|
||||||
<li>:o URL - Open URL</li>
|
|
||||||
<li>:q - Quit</li>
|
|
||||||
<li>? - Show this help</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>Forms</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Tab - Navigate links and form fields</li>
|
|
||||||
<li>Enter - Activate link or submit form</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
<p>TUT 2.0 - A modern terminal browser with True Color support</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
)";
|
|
||||||
current_tree = html_parser.parse_tree(help_html, "help://");
|
|
||||||
current_layout = layout_engine->layout(current_tree);
|
|
||||||
scroll_pos = 0;
|
|
||||||
active_link = current_tree.links.empty() ? -1 : 0;
|
|
||||||
status_message = "Help - Press any key to continue";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
BrowserV2::BrowserV2() : pImpl(std::make_unique<Impl>()) {
|
|
||||||
pImpl->input_handler.set_status_callback([this](const std::string& msg) {
|
|
||||||
pImpl->status_message = msg;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
BrowserV2::~BrowserV2() = default;
|
|
||||||
|
|
||||||
void BrowserV2::run(const std::string& initial_url) {
|
|
||||||
if (!pImpl->init_screen()) {
|
|
||||||
throw std::runtime_error("Failed to initialize terminal");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!initial_url.empty()) {
|
|
||||||
load_url(initial_url);
|
|
||||||
} else {
|
|
||||||
pImpl->show_help();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool running = true;
|
|
||||||
while (running) {
|
|
||||||
pImpl->draw_screen();
|
|
||||||
|
|
||||||
int ch = pImpl->terminal.get_key(50);
|
|
||||||
if (ch == -1) continue;
|
|
||||||
|
|
||||||
// 处理窗口大小变化
|
|
||||||
if (ch == KEY_RESIZE) {
|
|
||||||
pImpl->handle_resize();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto result = pImpl->input_handler.handle_key(ch);
|
|
||||||
if (result.action == Action::QUIT) {
|
|
||||||
running = false;
|
|
||||||
} else if (result.action != Action::NONE) {
|
|
||||||
pImpl->handle_action(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pImpl->cleanup_screen();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool BrowserV2::load_url(const std::string& url) {
|
|
||||||
return pImpl->load_page(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string BrowserV2::get_current_url() const {
|
|
||||||
return pImpl->current_url;
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "http_client.h"
|
|
||||||
#include "html_parser.h"
|
|
||||||
#include "input_handler.h"
|
|
||||||
#include "render/terminal.h"
|
|
||||||
#include "render/renderer.h"
|
|
||||||
#include "render/layout.h"
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
#include <memory>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BrowserV2 - 使用新渲染系统的浏览器
|
|
||||||
*
|
|
||||||
* 使用 Terminal + FrameBuffer + Renderer + LayoutEngine 架构
|
|
||||||
* 支持 True Color, Unicode, 差分渲染
|
|
||||||
*/
|
|
||||||
class BrowserV2 {
|
|
||||||
public:
|
|
||||||
BrowserV2();
|
|
||||||
~BrowserV2();
|
|
||||||
|
|
||||||
void run(const std::string& initial_url = "");
|
|
||||||
bool load_url(const std::string& url);
|
|
||||||
std::string get_current_url() const;
|
|
||||||
|
|
||||||
private:
|
|
||||||
class Impl;
|
|
||||||
std::unique_ptr<Impl> pImpl;
|
|
||||||
};
|
|
||||||
71
src/calendar.cpp
Normal file
71
src/calendar.cpp
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#include "calendar.h"
|
||||||
|
#include <iostream>
|
||||||
|
#include <vector>
|
||||||
|
#include <string>
|
||||||
|
#include <chrono>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "ics_fetcher.h"
|
||||||
|
#include "ics_parser.h"
|
||||||
|
#include "tui_view.h"
|
||||||
|
|
||||||
|
void Calendar::run() {
|
||||||
|
try {
|
||||||
|
// 1. 获取 ICS 文本
|
||||||
|
std::string url = "https://ical.nbtca.space/nbtca.ics";
|
||||||
|
std::string icsData = fetch_ics(url);
|
||||||
|
|
||||||
|
// 2. 解析事件
|
||||||
|
auto allEvents = parse_ics(icsData);
|
||||||
|
|
||||||
|
// 3. 过滤未来一个月的事件(支持简单的每周 RRULE)
|
||||||
|
auto now = std::chrono::system_clock::now();
|
||||||
|
auto oneMonthLater = now + std::chrono::hours(24 * 30);
|
||||||
|
|
||||||
|
std::vector<IcsEvent> upcoming;
|
||||||
|
for (const auto &ev : allEvents) {
|
||||||
|
// 简单处理:如果包含 FREQ=WEEKLY,则视为每周重复事件
|
||||||
|
if (ev.rrule.find("FREQ=WEEKLY") != std::string::npos) {
|
||||||
|
// 从基准时间往后每 7 天生成一次,直到超过 oneMonthLater 或 UNTIL
|
||||||
|
auto curStart = ev.start;
|
||||||
|
auto curEnd = ev.end;
|
||||||
|
|
||||||
|
// 如果有 UNTIL,则作为上界
|
||||||
|
auto upper = oneMonthLater;
|
||||||
|
if (ev.until && *ev.until < upper) {
|
||||||
|
upper = *ev.until;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为避免意外死循环,限制最多展开约 10 年(~520 周)
|
||||||
|
const int maxIterations = 520;
|
||||||
|
int iter = 0;
|
||||||
|
|
||||||
|
while (curStart <= upper && iter < maxIterations) {
|
||||||
|
if (curStart >= now && curStart <= upper) {
|
||||||
|
IcsEvent occ = ev;
|
||||||
|
occ.start = curStart;
|
||||||
|
occ.end = curEnd;
|
||||||
|
upcoming.push_back(std::move(occ));
|
||||||
|
}
|
||||||
|
curStart += std::chrono::hours(24 * 7);
|
||||||
|
curEnd += std::chrono::hours(24 * 7);
|
||||||
|
++iter;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非 RRULE 事件:直接按时间窗口筛选
|
||||||
|
if (ev.start >= now && ev.start <= oneMonthLater) {
|
||||||
|
upcoming.push_back(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保展示按时间排序
|
||||||
|
std::sort(upcoming.begin(), upcoming.end(),
|
||||||
|
[](const IcsEvent &a, const IcsEvent &b) { return a.start < b.start; });
|
||||||
|
|
||||||
|
// 4. 启动 TUI 展示(只展示未来一个月的活动)
|
||||||
|
run_tui(upcoming);
|
||||||
|
} catch (const std::exception &ex) {
|
||||||
|
std::cerr << "错误: " << ex.what() << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/calendar.h
Normal file
6
src/calendar.h
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
class Calendar {
|
||||||
|
public:
|
||||||
|
void run();
|
||||||
|
};
|
||||||
658
src/dom_tree.cpp
658
src/dom_tree.cpp
|
|
@ -1,658 +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 == "form" || tag_name == "fieldset";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (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, 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,
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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 (...) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 处理<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,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
110
src/dom_tree.h
110
src/dom_tree.h
|
|
@ -1,110 +0,0 @@
|
||||||
#pragma once
|
|
||||||
|
|
||||||
#include "html_parser.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表示未指定)
|
|
||||||
|
|
||||||
// 表格属性
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 辅助方法
|
|
||||||
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::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,
|
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
@ -1,102 +1,288 @@
|
||||||
#include "html_parser.h"
|
#include "html_parser.h"
|
||||||
#include "dom_tree.h"
|
#include <regex>
|
||||||
#include <stdexcept>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
// ============================================================================
|
#include <sstream>
|
||||||
// HtmlParser::Impl 实现
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
class HtmlParser::Impl {
|
class HtmlParser::Impl {
|
||||||
public:
|
public:
|
||||||
bool keep_code_blocks = true;
|
bool keep_code_blocks = true;
|
||||||
bool keep_lists = true;
|
bool keep_lists = true;
|
||||||
|
|
||||||
DomTreeBuilder tree_builder;
|
// 简单的HTML标签清理
|
||||||
|
std::string remove_tags(const std::string& html) {
|
||||||
DocumentTree parse_tree(const std::string& html, const std::string& base_url) {
|
std::string result;
|
||||||
return tree_builder.build(html, base_url);
|
bool in_tag = false;
|
||||||
|
for (char c : html) {
|
||||||
|
if (c == '<') {
|
||||||
|
in_tag = true;
|
||||||
|
} else if (c == '>') {
|
||||||
|
in_tag = false;
|
||||||
|
} else if (!in_tag) {
|
||||||
|
result += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将DocumentTree转换为ParsedDocument(向后兼容)
|
// 解码HTML实体
|
||||||
ParsedDocument convert_to_parsed_document(const DocumentTree& tree) {
|
std::string decode_html_entities(const std::string& text) {
|
||||||
ParsedDocument doc;
|
std::string result = text;
|
||||||
doc.title = tree.title;
|
|
||||||
doc.url = tree.url;
|
|
||||||
doc.links = tree.links;
|
|
||||||
|
|
||||||
// 递归遍历DOM树,收集ContentElement
|
// 常见HTML实体
|
||||||
if (tree.root) {
|
const std::vector<std::pair<std::string, std::string>> entities = {
|
||||||
collect_content_elements(tree.root.get(), doc.elements);
|
{" ", " "},
|
||||||
}
|
{"&", "&"},
|
||||||
|
{"<", "<"},
|
||||||
|
{">", ">"},
|
||||||
|
{""", "\""},
|
||||||
|
{"'", "'"},
|
||||||
|
{"'", "'"},
|
||||||
|
{"—", "\u2014"},
|
||||||
|
{"–", "\u2013"},
|
||||||
|
{"…", "..."},
|
||||||
|
{"“", "\u201C"},
|
||||||
|
{"”", "\u201D"},
|
||||||
|
{"‘", "\u2018"},
|
||||||
|
{"’", "\u2019"}
|
||||||
|
};
|
||||||
|
|
||||||
return doc;
|
for (const auto& [entity, replacement] : entities) {
|
||||||
}
|
size_t pos = 0;
|
||||||
|
while ((pos = result.find(entity, pos)) != std::string::npos) {
|
||||||
private:
|
result.replace(pos, entity.length(), replacement);
|
||||||
void collect_content_elements(DomNode* node, std::vector<ContentElement>& elements) {
|
pos += replacement.length();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 递归处理子节点
|
return result;
|
||||||
for (const auto& child : node->children) {
|
}
|
||||||
collect_content_elements(child.get(), elements);
|
|
||||||
|
// 提取标签内容
|
||||||
|
std::string extract_tag_content(const std::string& html, const std::string& tag) {
|
||||||
|
std::regex tag_regex("<" + tag + "[^>]*>([\\s\\S]*?)</" + tag + ">",
|
||||||
|
std::regex::icase);
|
||||||
|
std::smatch match;
|
||||||
|
if (std::regex_search(html, match, tag_regex)) {
|
||||||
|
return match[1].str();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取所有匹配的标签
|
||||||
|
std::vector<std::string> extract_all_tags(const std::string& html, const std::string& tag) {
|
||||||
|
std::vector<std::string> results;
|
||||||
|
std::regex tag_regex("<" + tag + "[^>]*>([\\s\\S]*?)</" + tag + ">",
|
||||||
|
std::regex::icase);
|
||||||
|
|
||||||
|
auto begin = std::sregex_iterator(html.begin(), html.end(), tag_regex);
|
||||||
|
auto end = std::sregex_iterator();
|
||||||
|
|
||||||
|
for (std::sregex_iterator i = begin; i != end; ++i) {
|
||||||
|
std::smatch match = *i;
|
||||||
|
results.push_back(match[1].str());
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取链接
|
||||||
|
std::vector<Link> extract_links(const std::string& html, const std::string& base_url) {
|
||||||
|
std::vector<Link> links;
|
||||||
|
std::regex link_regex(R"(<a\s+[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)</a>)",
|
||||||
|
std::regex::icase);
|
||||||
|
|
||||||
|
auto begin = std::sregex_iterator(html.begin(), html.end(), link_regex);
|
||||||
|
auto end = std::sregex_iterator();
|
||||||
|
|
||||||
|
int position = 0;
|
||||||
|
for (std::sregex_iterator i = begin; i != end; ++i) {
|
||||||
|
std::smatch match = *i;
|
||||||
|
Link link;
|
||||||
|
link.url = match[1].str();
|
||||||
|
link.text = decode_html_entities(remove_tags(match[2].str()));
|
||||||
|
link.position = position++;
|
||||||
|
|
||||||
|
// 处理相对URL
|
||||||
|
if (!link.url.empty() && link.url[0] != '#') {
|
||||||
|
// 如果是相对路径
|
||||||
|
if (link.url.find("://") == std::string::npos) {
|
||||||
|
// 提取base_url的协议和域名
|
||||||
|
std::regex base_regex(R"((https?://[^/]+)(/.*)?)", std::regex::icase);
|
||||||
|
std::smatch base_match;
|
||||||
|
if (std::regex_match(base_url, base_match, base_regex)) {
|
||||||
|
std::string base_domain = base_match[1].str();
|
||||||
|
std::string base_path = base_match[2].str();
|
||||||
|
|
||||||
|
if (link.url[0] == '/') {
|
||||||
|
// 绝对路径(从根目录开始)
|
||||||
|
link.url = base_domain + link.url;
|
||||||
|
} else {
|
||||||
|
// 相对路径
|
||||||
|
// 获取当前页面的目录
|
||||||
|
size_t last_slash = base_path.rfind('/');
|
||||||
|
std::string current_dir = (last_slash != std::string::npos)
|
||||||
|
? base_path.substr(0, last_slash + 1)
|
||||||
|
: "/";
|
||||||
|
link.url = base_domain + current_dir + link.url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void collect_inline_links(DomNode* node, std::vector<InlineLink>& links) {
|
// 过滤空链接文本
|
||||||
if (!node) return;
|
if (!link.text.empty()) {
|
||||||
|
|
||||||
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);
|
links.push_back(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const auto& child : node->children) {
|
|
||||||
collect_inline_links(child.get(), links);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理空白字符
|
||||||
|
std::string trim(const std::string& str) {
|
||||||
|
auto start = str.begin();
|
||||||
|
while (start != str.end() && std::isspace(*start)) {
|
||||||
|
++start;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto end = str.end();
|
||||||
|
do {
|
||||||
|
--end;
|
||||||
|
} while (std::distance(start, end) > 0 && std::isspace(*end));
|
||||||
|
|
||||||
|
return std::string(start, end + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除脚本和样式
|
||||||
|
std::string remove_scripts_and_styles(const std::string& html) {
|
||||||
|
std::string result = html;
|
||||||
|
|
||||||
|
// 移除script标签
|
||||||
|
result = std::regex_replace(result,
|
||||||
|
std::regex("<script[^>]*>[\\s\\S]*?</script>", std::regex::icase),
|
||||||
|
"");
|
||||||
|
|
||||||
|
// 移除style标签
|
||||||
|
result = std::regex_replace(result,
|
||||||
|
std::regex("<style[^>]*>[\\s\\S]*?</style>", std::regex::icase),
|
||||||
|
"");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// HtmlParser 公共接口实现
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
HtmlParser::HtmlParser() : pImpl(std::make_unique<Impl>()) {}
|
HtmlParser::HtmlParser() : pImpl(std::make_unique<Impl>()) {}
|
||||||
|
|
||||||
HtmlParser::~HtmlParser() = default;
|
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) {
|
ParsedDocument HtmlParser::parse(const std::string& html, const std::string& base_url) {
|
||||||
// 使用新的DOM树解析,然后转换为旧格式
|
ParsedDocument doc;
|
||||||
DocumentTree tree = pImpl->parse_tree(html, base_url);
|
doc.url = base_url;
|
||||||
return pImpl->convert_to_parsed_document(tree);
|
|
||||||
|
// 清理HTML
|
||||||
|
std::string clean_html = pImpl->remove_scripts_and_styles(html);
|
||||||
|
|
||||||
|
// 提取标题
|
||||||
|
std::string title_content = pImpl->extract_tag_content(clean_html, "title");
|
||||||
|
doc.title = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(title_content)));
|
||||||
|
|
||||||
|
if (doc.title.empty()) {
|
||||||
|
std::string h1_content = pImpl->extract_tag_content(clean_html, "h1");
|
||||||
|
doc.title = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(h1_content)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取主要内容区域(article, main, 或 body)
|
||||||
|
std::string main_content = pImpl->extract_tag_content(clean_html, "article");
|
||||||
|
if (main_content.empty()) {
|
||||||
|
main_content = pImpl->extract_tag_content(clean_html, "main");
|
||||||
|
}
|
||||||
|
if (main_content.empty()) {
|
||||||
|
main_content = pImpl->extract_tag_content(clean_html, "body");
|
||||||
|
}
|
||||||
|
if (main_content.empty()) {
|
||||||
|
main_content = clean_html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取链接
|
||||||
|
doc.links = pImpl->extract_links(main_content, base_url);
|
||||||
|
|
||||||
|
// 解析标题
|
||||||
|
for (int level = 1; level <= 6; ++level) {
|
||||||
|
std::string tag = "h" + std::to_string(level);
|
||||||
|
auto headings = pImpl->extract_all_tags(main_content, tag);
|
||||||
|
for (const auto& heading : headings) {
|
||||||
|
ContentElement elem;
|
||||||
|
elem.type = (level == 1) ? ElementType::HEADING1 :
|
||||||
|
(level == 2) ? ElementType::HEADING2 : ElementType::HEADING3;
|
||||||
|
elem.text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(heading)));
|
||||||
|
elem.level = level;
|
||||||
|
if (!elem.text.empty()) {
|
||||||
|
doc.elements.push_back(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析列表项
|
||||||
|
if (pImpl->keep_lists) {
|
||||||
|
auto list_items = pImpl->extract_all_tags(main_content, "li");
|
||||||
|
for (const auto& item : list_items) {
|
||||||
|
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(item)));
|
||||||
|
if (!text.empty() && text.length() > 1) {
|
||||||
|
ContentElement elem;
|
||||||
|
elem.type = ElementType::LIST_ITEM;
|
||||||
|
elem.text = text;
|
||||||
|
doc.elements.push_back(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析段落
|
||||||
|
auto paragraphs = pImpl->extract_all_tags(main_content, "p");
|
||||||
|
for (const auto& para : paragraphs) {
|
||||||
|
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(para)));
|
||||||
|
if (!text.empty() && text.length() > 1) {
|
||||||
|
ContentElement elem;
|
||||||
|
elem.type = ElementType::PARAGRAPH;
|
||||||
|
elem.text = text;
|
||||||
|
doc.elements.push_back(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果内容很少,尝试提取div中的文本
|
||||||
|
if (doc.elements.size() < 3) {
|
||||||
|
auto divs = pImpl->extract_all_tags(main_content, "div");
|
||||||
|
for (const auto& div : divs) {
|
||||||
|
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(div)));
|
||||||
|
if (!text.empty() && text.length() > 20) { // 忽略太短的div
|
||||||
|
ContentElement elem;
|
||||||
|
elem.type = ElementType::PARAGRAPH;
|
||||||
|
elem.text = text;
|
||||||
|
doc.elements.push_back(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍然没有内容,尝试提取整个文本
|
||||||
|
if (doc.elements.empty()) {
|
||||||
|
std::string all_text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(main_content)));
|
||||||
|
if (!all_text.empty()) {
|
||||||
|
// 按换行符分割
|
||||||
|
std::istringstream iss(all_text);
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(iss, line)) {
|
||||||
|
line = pImpl->trim(line);
|
||||||
|
if (!line.empty() && line.length() > 1) {
|
||||||
|
ContentElement elem;
|
||||||
|
elem.type = ElementType::PARAGRAPH;
|
||||||
|
elem.text = line;
|
||||||
|
doc.elements.push_back(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
void HtmlParser::set_keep_code_blocks(bool keep) {
|
void HtmlParser::set_keep_code_blocks(bool keep) {
|
||||||
|
|
|
||||||
|
|
@ -4,41 +4,18 @@
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
// Forward declaration
|
|
||||||
struct DocumentTree;
|
|
||||||
|
|
||||||
enum class ElementType {
|
enum class ElementType {
|
||||||
TEXT,
|
TEXT,
|
||||||
HEADING1,
|
HEADING1,
|
||||||
HEADING2,
|
HEADING2,
|
||||||
HEADING3,
|
HEADING3,
|
||||||
HEADING4,
|
|
||||||
HEADING5,
|
|
||||||
HEADING6,
|
|
||||||
PARAGRAPH,
|
PARAGRAPH,
|
||||||
LINK,
|
LINK,
|
||||||
LIST_ITEM,
|
LIST_ITEM,
|
||||||
ORDERED_LIST_ITEM,
|
|
||||||
BLOCKQUOTE,
|
BLOCKQUOTE,
|
||||||
CODE_BLOCK,
|
CODE_BLOCK,
|
||||||
HORIZONTAL_RULE,
|
HORIZONTAL_RULE,
|
||||||
LINE_BREAK,
|
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 {
|
struct Link {
|
||||||
|
|
@ -47,66 +24,11 @@ struct Link {
|
||||||
int position;
|
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 {
|
struct ContentElement {
|
||||||
ElementType type;
|
ElementType type;
|
||||||
std::string text;
|
std::string text;
|
||||||
std::string url;
|
std::string url;
|
||||||
int level;
|
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 {
|
struct ParsedDocument {
|
||||||
|
|
@ -121,12 +43,7 @@ public:
|
||||||
HtmlParser();
|
HtmlParser();
|
||||||
~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 = "");
|
ParsedDocument parse(const std::string& html, const std::string& base_url = "");
|
||||||
|
|
||||||
void set_keep_code_blocks(bool keep);
|
void set_keep_code_blocks(bool keep);
|
||||||
void set_keep_lists(bool keep);
|
void set_keep_lists(bool keep);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,19 @@
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
|
||||||
// 回调函数用于接收文本数据
|
// 回调函数用于接收数据
|
||||||
static size_t write_callback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
static size_t write_callback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
||||||
size_t total_size = size * nmemb;
|
size_t total_size = size * nmemb;
|
||||||
userp->append(static_cast<char*>(contents), total_size);
|
userp->append(static_cast<char*>(contents), total_size);
|
||||||
return 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
class HttpClient::Impl {
|
class HttpClient::Impl {
|
||||||
public:
|
public:
|
||||||
CURL* curl;
|
CURL* curl;
|
||||||
long timeout;
|
long timeout;
|
||||||
std::string user_agent;
|
std::string user_agent;
|
||||||
bool follow_redirects;
|
bool follow_redirects;
|
||||||
std::string cookie_file;
|
|
||||||
|
|
||||||
Impl() : timeout(30),
|
Impl() : timeout(30),
|
||||||
user_agent("TUT-Browser/1.0 (Terminal User Interface Browser)"),
|
user_agent("TUT-Browser/1.0 (Terminal User Interface Browser)"),
|
||||||
|
|
@ -32,10 +23,6 @@ public:
|
||||||
if (!curl) {
|
if (!curl) {
|
||||||
throw std::runtime_error("Failed to initialize 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, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
~Impl() {
|
~Impl() {
|
||||||
|
|
@ -58,15 +45,9 @@ HttpResponse HttpClient::fetch(const std::string& url) {
|
||||||
return response;
|
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);
|
curl_easy_reset(pImpl->curl);
|
||||||
|
|
||||||
// Re-apply settings
|
|
||||||
// 设置URL
|
// 设置URL
|
||||||
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
|
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
|
||||||
|
|
||||||
|
|
@ -92,14 +73,6 @@ HttpResponse HttpClient::fetch(const std::string& url) {
|
||||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
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);
|
CURLcode res = curl_easy_perform(pImpl->curl);
|
||||||
|
|
||||||
|
|
@ -125,164 +98,6 @@ HttpResponse HttpClient::fetch(const std::string& url) {
|
||||||
return response;
|
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) {
|
void HttpClient::set_timeout(long timeout_seconds) {
|
||||||
pImpl->timeout = timeout_seconds;
|
pImpl->timeout = timeout_seconds;
|
||||||
}
|
}
|
||||||
|
|
@ -294,7 +109,3 @@ void HttpClient::set_user_agent(const std::string& user_agent) {
|
||||||
void HttpClient::set_follow_redirects(bool follow) {
|
void HttpClient::set_follow_redirects(bool follow) {
|
||||||
pImpl->follow_redirects = follow;
|
pImpl->follow_redirects = follow;
|
||||||
}
|
}
|
||||||
|
|
||||||
void HttpClient::enable_cookies(const std::string& cookie_file) {
|
|
||||||
pImpl->cookie_file = cookie_file;
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
struct HttpResponse {
|
struct HttpResponse {
|
||||||
|
|
@ -14,21 +12,6 @@ struct HttpResponse {
|
||||||
bool is_success() const {
|
bool is_success() const {
|
||||||
return status_code >= 200 && status_code < 300;
|
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class HttpClient {
|
class HttpClient {
|
||||||
|
|
@ -37,13 +20,9 @@ public:
|
||||||
~HttpClient();
|
~HttpClient();
|
||||||
|
|
||||||
HttpResponse fetch(const std::string& url);
|
HttpResponse fetch(const std::string& url);
|
||||||
BinaryResponse fetch_binary(const std::string& url);
|
|
||||||
HttpResponse post(const std::string& url, const std::string& data,
|
|
||||||
const std::string& content_type = "application/x-www-form-urlencoded");
|
|
||||||
void set_timeout(long timeout_seconds);
|
void set_timeout(long timeout_seconds);
|
||||||
void set_user_agent(const std::string& user_agent);
|
void set_user_agent(const std::string& user_agent);
|
||||||
void set_follow_redirects(bool follow);
|
void set_follow_redirects(bool follow);
|
||||||
void enable_cookies(const std::string& cookie_file = "");
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
class Impl;
|
class Impl;
|
||||||
|
|
|
||||||
46
src/ics_fetcher.cpp
Normal file
46
src/ics_fetcher.cpp
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
#include "ics_fetcher.h"
|
||||||
|
|
||||||
|
#include <curl/curl.h>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
size_t write_callback(char *ptr, size_t size, size_t nmemb, void *userdata) {
|
||||||
|
auto *buffer = static_cast<std::string *>(userdata);
|
||||||
|
buffer->append(ptr, size * nmemb);
|
||||||
|
return size * nmemb;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string fetch_ics(const std::string &url) {
|
||||||
|
CURL *curl = curl_easy_init();
|
||||||
|
if (!curl) {
|
||||||
|
throw std::runtime_error("初始化 libcurl 失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string response;
|
||||||
|
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
||||||
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "nbtca_tui/1.0");
|
||||||
|
|
||||||
|
CURLcode res = curl_easy_perform(curl);
|
||||||
|
if (res != CURLE_OK) {
|
||||||
|
std::string err = curl_easy_strerror(res);
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
throw std::runtime_error("请求 ICS 失败: " + err);
|
||||||
|
}
|
||||||
|
|
||||||
|
long http_code = 0;
|
||||||
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
|
curl_easy_cleanup(curl);
|
||||||
|
|
||||||
|
if (http_code < 200 || http_code >= 300) {
|
||||||
|
throw std::runtime_error("HTTP 状态码错误: " + std::to_string(http_code));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
8
src/ics_fetcher.h
Normal file
8
src/ics_fetcher.h
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// 从给定 URL 获取 ICS 文本,失败抛出 std::runtime_error
|
||||||
|
std::string fetch_ics(const std::string &url);
|
||||||
|
|
||||||
|
|
||||||
165
src/ics_parser.cpp
Normal file
165
src/ics_parser.cpp
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
#include "ics_parser.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <optional>
|
||||||
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 去掉首尾空白
|
||||||
|
std::string trim(const std::string &s) {
|
||||||
|
size_t start = 0;
|
||||||
|
while (start < s.size() && std::isspace(static_cast<unsigned char>(s[start]))) ++start;
|
||||||
|
size_t end = s.size();
|
||||||
|
while (end > start && std::isspace(static_cast<unsigned char>(s[end - 1]))) --end;
|
||||||
|
return s.substr(start, end - start);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 ICS 中换行折叠处理(以空格或 Tab 开头的行拼接到前一行)
|
||||||
|
std::vector<std::string> unfold_lines(const std::string &text) {
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
std::string current;
|
||||||
|
std::istringstream iss(text);
|
||||||
|
std::string line;
|
||||||
|
while (std::getline(iss, line)) {
|
||||||
|
if (!line.empty() && (line.back() == '\r' || line.back() == '\n')) {
|
||||||
|
line.pop_back();
|
||||||
|
}
|
||||||
|
if (!line.empty() && (line[0] == ' ' || line[0] == '\t')) {
|
||||||
|
// continuation
|
||||||
|
current += trim(line);
|
||||||
|
} else {
|
||||||
|
if (!current.empty()) {
|
||||||
|
lines.push_back(current);
|
||||||
|
}
|
||||||
|
current = line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!current.empty()) {
|
||||||
|
lines.push_back(current);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅支持几种常见格式:YYYYMMDD 或 YYYYMMDDTHHMMSSZ / 本地时间
|
||||||
|
std::chrono::system_clock::time_point parse_ics_datetime(const std::string &value) {
|
||||||
|
std::tm tm{};
|
||||||
|
if (value.size() == 8) {
|
||||||
|
// 日期
|
||||||
|
std::istringstream ss(value);
|
||||||
|
ss >> std::get_time(&tm, "%Y%m%d");
|
||||||
|
if (ss.fail()) {
|
||||||
|
throw std::runtime_error("无法解析日期: " + value);
|
||||||
|
}
|
||||||
|
tm.tm_hour = 0;
|
||||||
|
tm.tm_min = 0;
|
||||||
|
tm.tm_sec = 0;
|
||||||
|
} else if (value.size() >= 15 && value[8] == 'T') {
|
||||||
|
// 日期时间,如 20250101T090000Z 或无 Z
|
||||||
|
std::string fmt = "%Y%m%dT%H%M%S";
|
||||||
|
std::string v = value;
|
||||||
|
bool hasZ = false;
|
||||||
|
if (!v.empty() && v.back() == 'Z') {
|
||||||
|
hasZ = true;
|
||||||
|
v.pop_back();
|
||||||
|
}
|
||||||
|
std::istringstream ss(v);
|
||||||
|
ss >> std::get_time(&tm, fmt.c_str());
|
||||||
|
if (ss.fail()) {
|
||||||
|
throw std::runtime_error("无法解析日期时间: " + value);
|
||||||
|
}
|
||||||
|
// 这里简单按本地时间处理;如需严格 UTC 可改用 timegm
|
||||||
|
} else {
|
||||||
|
throw std::runtime_error("未知日期格式: " + value);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::time_t t = std::mktime(&tm);
|
||||||
|
return std::chrono::system_clock::from_time_t(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string get_prop_value(const std::string &line) {
|
||||||
|
auto pos = line.find(':');
|
||||||
|
if (pos == std::string::npos) return {};
|
||||||
|
return line.substr(pos + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取属性参数和值,例如 "DTSTART;TZID=Asia/Shanghai:20251121T203000"
|
||||||
|
// 返回 "DTSTART;TZID=Asia/Shanghai" 作为 key,冒号后的部分作为 value。
|
||||||
|
std::pair<std::string, std::string> split_prop(const std::string &line) {
|
||||||
|
auto pos = line.find(':');
|
||||||
|
if (pos == std::string::npos) return {line, {}};
|
||||||
|
return {line.substr(0, pos), line.substr(pos + 1)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 RRULE 中提取 UNTIL=... 的值(若存在)
|
||||||
|
std::optional<std::string> extract_until_str(const std::string &rrule) {
|
||||||
|
// 例:RRULE:FREQ=WEEKLY;BYDAY=FR;UNTIL=20260401T000000Z
|
||||||
|
auto pos = rrule.find("UNTIL=");
|
||||||
|
if (pos == std::string::npos) return std::nullopt;
|
||||||
|
pos += 6; // 跳过 "UNTIL="
|
||||||
|
size_t end = rrule.find(';', pos);
|
||||||
|
if (end == std::string::npos) end = rrule.size();
|
||||||
|
if (pos >= rrule.size()) return std::nullopt;
|
||||||
|
return rrule.substr(pos, end - pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool starts_with(const std::string &s, const std::string &prefix) {
|
||||||
|
return s.size() >= prefix.size() && std::equal(prefix.begin(), prefix.end(), s.begin());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<IcsEvent> parse_ics(const std::string &icsText) {
|
||||||
|
auto lines = unfold_lines(icsText);
|
||||||
|
std::vector<IcsEvent> events;
|
||||||
|
|
||||||
|
bool inEvent = false;
|
||||||
|
IcsEvent current{};
|
||||||
|
|
||||||
|
for (const auto &rawLine : lines) {
|
||||||
|
std::string line = trim(rawLine);
|
||||||
|
if (line == "BEGIN:VEVENT") {
|
||||||
|
inEvent = true;
|
||||||
|
current = IcsEvent{};
|
||||||
|
} else if (line == "END:VEVENT") {
|
||||||
|
if (inEvent) {
|
||||||
|
events.push_back(current);
|
||||||
|
}
|
||||||
|
inEvent = false;
|
||||||
|
} else if (inEvent) {
|
||||||
|
if (starts_with(line, "DTSTART")) {
|
||||||
|
auto [key, v] = split_prop(line);
|
||||||
|
current.start = parse_ics_datetime(v);
|
||||||
|
} else if (starts_with(line, "DTEND")) {
|
||||||
|
auto [key, v] = split_prop(line);
|
||||||
|
current.end = parse_ics_datetime(v);
|
||||||
|
} else if (starts_with(line, "SUMMARY")) {
|
||||||
|
current.summary = get_prop_value(line);
|
||||||
|
} else if (starts_with(line, "LOCATION")) {
|
||||||
|
current.location = get_prop_value(line);
|
||||||
|
} else if (starts_with(line, "DESCRIPTION")) {
|
||||||
|
current.description = get_prop_value(line);
|
||||||
|
} else if (starts_with(line, "RRULE")) {
|
||||||
|
current.rrule = get_prop_value(line);
|
||||||
|
if (auto untilStr = extract_until_str(current.rrule)) {
|
||||||
|
try {
|
||||||
|
current.until = parse_ics_datetime(*untilStr);
|
||||||
|
} catch (...) {
|
||||||
|
// UNTIL 解析失败时忽略截止时间,照常视为无限期
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按开始时间排序
|
||||||
|
std::sort(events.begin(), events.end(),
|
||||||
|
[](const IcsEvent &a, const IcsEvent &b) { return a.start < b.start; });
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
24
src/ics_parser.h
Normal file
24
src/ics_parser.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct IcsEvent {
|
||||||
|
std::chrono::system_clock::time_point start;
|
||||||
|
std::chrono::system_clock::time_point end;
|
||||||
|
std::string summary;
|
||||||
|
std::string location;
|
||||||
|
std::string description;
|
||||||
|
|
||||||
|
// 简单递归支持:保留 RRULE 原文,以及可选的 UNTIL 截止时间(若存在)
|
||||||
|
std::string rrule;
|
||||||
|
std::optional<std::chrono::system_clock::time_point> until;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析 ICS 文本,返回“基准事件”(不展开 RRULE)。
|
||||||
|
// 周期事件会在 IcsEvent::rrule 与 IcsEvent::until 中体现。
|
||||||
|
std::vector<IcsEvent> parse_ics(const std::string &icsText);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,36 +23,6 @@ public:
|
||||||
result.has_count = false;
|
result.has_count = false;
|
||||||
result.count = 1;
|
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())) {
|
if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) {
|
||||||
count_buffer += static_cast<char>(ch);
|
count_buffer += static_cast<char>(ch);
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -61,38 +31,33 @@ public:
|
||||||
if (!count_buffer.empty()) {
|
if (!count_buffer.empty()) {
|
||||||
result.has_count = true;
|
result.has_count = true;
|
||||||
result.count = std::stoi(count_buffer);
|
result.count = std::stoi(count_buffer);
|
||||||
|
count_buffer.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (ch) {
|
switch (ch) {
|
||||||
case 'j':
|
case 'j':
|
||||||
case KEY_DOWN:
|
case KEY_DOWN:
|
||||||
result.action = Action::SCROLL_DOWN;
|
result.action = Action::SCROLL_DOWN;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case 'k':
|
case 'k':
|
||||||
case KEY_UP:
|
case KEY_UP:
|
||||||
result.action = Action::SCROLL_UP;
|
result.action = Action::SCROLL_UP;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case 'h':
|
case 'h':
|
||||||
case KEY_LEFT:
|
case KEY_LEFT:
|
||||||
result.action = Action::GO_BACK;
|
result.action = Action::GO_BACK;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case 'l':
|
case 'l':
|
||||||
case KEY_RIGHT:
|
case KEY_RIGHT:
|
||||||
result.action = Action::GO_FORWARD;
|
result.action = Action::GO_FORWARD;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
case ' ':
|
case ' ':
|
||||||
result.action = Action::SCROLL_PAGE_DOWN;
|
result.action = Action::SCROLL_PAGE_DOWN;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case 21:
|
case 21:
|
||||||
case 'b':
|
case 'b':
|
||||||
result.action = Action::SCROLL_PAGE_UP;
|
result.action = Action::SCROLL_PAGE_UP;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case 'g':
|
case 'g':
|
||||||
buffer += 'g';
|
buffer += 'g';
|
||||||
|
|
@ -100,7 +65,6 @@ public:
|
||||||
result.action = Action::GOTO_TOP;
|
result.action = Action::GOTO_TOP;
|
||||||
buffer.clear();
|
buffer.clear();
|
||||||
}
|
}
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case 'G':
|
case 'G':
|
||||||
if (result.has_count) {
|
if (result.has_count) {
|
||||||
|
|
@ -109,57 +73,27 @@ public:
|
||||||
} else {
|
} else {
|
||||||
result.action = Action::GOTO_BOTTOM;
|
result.action = Action::GOTO_BOTTOM;
|
||||||
}
|
}
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case '/':
|
case '/':
|
||||||
mode = InputMode::SEARCH;
|
mode = InputMode::SEARCH;
|
||||||
buffer = "/";
|
buffer = "/";
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case 'n':
|
case 'n':
|
||||||
result.action = Action::SEARCH_NEXT;
|
result.action = Action::SEARCH_NEXT;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case 'N':
|
case 'N':
|
||||||
result.action = Action::SEARCH_PREV;
|
result.action = Action::SEARCH_PREV;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case '\t':
|
case '\t':
|
||||||
result.action = Action::NEXT_LINK;
|
result.action = Action::NEXT_LINK;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case KEY_BTAB:
|
case KEY_BTAB:
|
||||||
case 'T':
|
case 'T':
|
||||||
result.action = Action::PREV_LINK;
|
result.action = Action::PREV_LINK;
|
||||||
count_buffer.clear();
|
|
||||||
break;
|
break;
|
||||||
case '\n':
|
case '\n':
|
||||||
case '\r':
|
case '\r':
|
||||||
// If count buffer has a number, jump to that link
|
|
||||||
if (result.has_count) {
|
|
||||||
result.action = Action::GOTO_LINK;
|
|
||||||
result.number = result.count;
|
|
||||||
} else {
|
|
||||||
result.action = Action::FOLLOW_LINK;
|
result.action = Action::FOLLOW_LINK;
|
||||||
}
|
|
||||||
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;
|
break;
|
||||||
case ':':
|
case ':':
|
||||||
mode = InputMode::COMMAND;
|
mode = InputMode::COMMAND;
|
||||||
|
|
@ -256,74 +190,6 @@ public:
|
||||||
|
|
||||||
return result;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
||||||
|
|
@ -338,10 +204,6 @@ InputResult InputHandler::handle_key(int ch) {
|
||||||
return pImpl->process_command_mode(ch);
|
return pImpl->process_command_mode(ch);
|
||||||
case InputMode::SEARCH:
|
case InputMode::SEARCH:
|
||||||
return pImpl->process_search_mode(ch);
|
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);
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ enum class InputMode {
|
||||||
NORMAL,
|
NORMAL,
|
||||||
COMMAND,
|
COMMAND,
|
||||||
SEARCH,
|
SEARCH,
|
||||||
LINK,
|
LINK
|
||||||
LINK_HINTS // Vimium-style 'f' mode
|
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class Action {
|
enum class Action {
|
||||||
|
|
@ -27,18 +26,12 @@ enum class Action {
|
||||||
NEXT_LINK,
|
NEXT_LINK,
|
||||||
PREV_LINK,
|
PREV_LINK,
|
||||||
FOLLOW_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_BACK,
|
||||||
GO_FORWARD,
|
GO_FORWARD,
|
||||||
OPEN_URL,
|
OPEN_URL,
|
||||||
REFRESH,
|
REFRESH,
|
||||||
QUIT,
|
QUIT,
|
||||||
HELP,
|
HELP
|
||||||
SET_MARK, // Set a mark (m + letter)
|
|
||||||
GOTO_MARK // Jump to mark (' + letter)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct InputResult {
|
struct InputResult {
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
#include "browser_v2.h"
|
|
||||||
#include <iostream>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
void print_usage(const char* prog_name) {
|
|
||||||
std::cout << "TUT 2.0 - Terminal User Interface Browser\n"
|
|
||||||
<< "A vim-style terminal web browser with True Color support\n\n"
|
|
||||||
<< "Usage: " << prog_name << " [URL]\n\n"
|
|
||||||
<< "If no URL is provided, the browser will start with a help page.\n\n"
|
|
||||||
<< "Examples:\n"
|
|
||||||
<< " " << prog_name << "\n"
|
|
||||||
<< " " << prog_name << " https://example.com\n"
|
|
||||||
<< " " << prog_name << " https://news.ycombinator.com\n\n"
|
|
||||||
<< "Vim-style keybindings:\n"
|
|
||||||
<< " j/k - Scroll down/up\n"
|
|
||||||
<< " gg/G - Go to top/bottom\n"
|
|
||||||
<< " / - Search\n"
|
|
||||||
<< " Tab - Next link\n"
|
|
||||||
<< " Enter - Follow link\n"
|
|
||||||
<< " h/l - Back/Forward\n"
|
|
||||||
<< " :o URL - Open URL\n"
|
|
||||||
<< " :q - Quit\n"
|
|
||||||
<< " ? - Show help\n\n"
|
|
||||||
<< "New in 2.0:\n"
|
|
||||||
<< " - True Color (24-bit) support\n"
|
|
||||||
<< " - Improved Unicode handling\n"
|
|
||||||
<< " - Differential rendering for better performance\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char* argv[]) {
|
|
||||||
std::string initial_url;
|
|
||||||
|
|
||||||
if (argc > 1) {
|
|
||||||
if (std::strcmp(argv[1], "-h") == 0 || std::strcmp(argv[1], "--help") == 0) {
|
|
||||||
print_usage(argv[0]);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
initial_url = argv[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
BrowserV2 browser;
|
|
||||||
browser.run(initial_url);
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
std::cerr << "Error: " << e.what() << std::endl;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
@ -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,714 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理容器元素(html, body, div, form等)- 递归处理子节点
|
|
||||||
if (node->tag_name == "html" || node->tag_name == "body" ||
|
|
||||||
node->tag_name == "head" || node->tag_name == "main" ||
|
|
||||||
node->tag_name == "article" || node->tag_name == "section" ||
|
|
||||||
node->tag_name == "div" || node->tag_name == "header" ||
|
|
||||||
node->tag_name == "footer" || node->tag_name == "nav" ||
|
|
||||||
node->tag_name == "aside" || node->tag_name == "form" ||
|
|
||||||
node->tag_name == "fieldset") {
|
|
||||||
for (const auto& child : node->children) {
|
|
||||||
layout_node(child.get(), ctx, blocks);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理表单内联元素
|
|
||||||
if (node->element_type == ElementType::INPUT ||
|
|
||||||
node->element_type == ElementType::BUTTON ||
|
|
||||||
node->element_type == ElementType::TEXTAREA ||
|
|
||||||
node->element_type == ElementType::SELECT) {
|
|
||||||
layout_form_element(node, ctx, blocks);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理图片元素
|
|
||||||
if (node->element_type == ElementType::IMAGE) {
|
|
||||||
layout_image_element(node, ctx, blocks);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node->is_block_element()) {
|
|
||||||
layout_block_element(node, ctx, blocks);
|
|
||||||
}
|
|
||||||
// 内联元素在块级元素内部处理
|
|
||||||
}
|
|
||||||
|
|
||||||
void LayoutEngine::layout_block_element(const DomNode* node, Context& ctx, std::vector<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;
|
|
||||||
span.text = "[▼ Select]";
|
|
||||||
span.fg = colors::INPUT_FOCUS;
|
|
||||||
span.bg = colors::INPUT_BG;
|
|
||||||
span.field_index = node->field_index;
|
|
||||||
line.spans.push_back(span);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!line.spans.empty()) {
|
|
||||||
block.lines.push_back(line);
|
|
||||||
blocks.push_back(block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, std::vector<LayoutBlock>& blocks) {
|
|
||||||
LayoutBlock block;
|
|
||||||
block.type = ElementType::IMAGE;
|
|
||||||
block.margin_top = 0;
|
|
||||||
block.margin_bottom = 1;
|
|
||||||
|
|
||||||
LayoutLine line;
|
|
||||||
line.indent = MARGIN_LEFT;
|
|
||||||
|
|
||||||
// 生成图片占位符
|
|
||||||
std::string placeholder = make_image_placeholder(node->alt_text, node->img_src);
|
|
||||||
|
|
||||||
StyledSpan span;
|
|
||||||
span.text = placeholder;
|
|
||||||
span.fg = colors::FG_DIM; // 使用较暗的颜色表示占位符
|
|
||||||
span.attrs = ATTR_NONE;
|
|
||||||
|
|
||||||
line.spans.push_back(span);
|
|
||||||
block.lines.push_back(line);
|
|
||||||
blocks.push_back(block);
|
|
||||||
}
|
|
||||||
|
|
||||||
void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std::vector<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 (const auto& child : node->children) {
|
|
||||||
if (child->node_type == NodeType::TEXT) {
|
|
||||||
StyledSpan span;
|
|
||||||
span.text = child->text_content;
|
|
||||||
span.fg = fg;
|
|
||||||
span.attrs = attrs;
|
|
||||||
span.link_index = link_idx;
|
|
||||||
|
|
||||||
if (ctx.in_blockquote) {
|
|
||||||
span.fg = colors::QUOTE_FG;
|
|
||||||
}
|
|
||||||
|
|
||||||
spans.push_back(span);
|
|
||||||
} else if (!child->is_block_element()) {
|
|
||||||
collect_inline_content(child.get(), ctx, spans);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void LayoutEngine::layout_text(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
|
|
||||||
if (!node || node->text_content.empty()) return;
|
|
||||||
|
|
||||||
StyledSpan span;
|
|
||||||
span.text = node->text_content;
|
|
||||||
span.fg = colors::FG_PRIMARY;
|
|
||||||
|
|
||||||
if (ctx.in_blockquote) {
|
|
||||||
span.fg = colors::QUOTE_FG;
|
|
||||||
}
|
|
||||||
|
|
||||||
spans.push_back(span);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<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;
|
|
||||||
|
|
||||||
for (const auto& span : spans) {
|
|
||||||
// 分词处理
|
|
||||||
std::istringstream iss(span.text);
|
|
||||||
std::string word;
|
|
||||||
bool first_word = true;
|
|
||||||
|
|
||||||
while (iss >> word) {
|
|
||||||
size_t word_width = Unicode::display_width(word);
|
|
||||||
|
|
||||||
// 检查是否需要换行
|
|
||||||
if (current_width > 0 && current_width + 1 + word_width > static_cast<size_t>(available_width)) {
|
|
||||||
// 当前行已满,开始新行
|
|
||||||
if (!current_line.spans.empty()) {
|
|
||||||
lines.push_back(current_line);
|
|
||||||
}
|
|
||||||
current_line = LayoutLine();
|
|
||||||
current_line.indent = indent;
|
|
||||||
current_width = 0;
|
|
||||||
first_word = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加空格(如果不是行首)
|
|
||||||
if (current_width > 0 && !first_word) {
|
|
||||||
if (!current_line.spans.empty()) {
|
|
||||||
current_line.spans.back().text += " ";
|
|
||||||
current_width += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加单词
|
|
||||||
StyledSpan word_span = span;
|
|
||||||
word_span.text = word;
|
|
||||||
current_line.spans.push_back(word_span);
|
|
||||||
current_width += word_width;
|
|
||||||
first_word = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加最后一行
|
|
||||||
if (!current_line.spans.empty()) {
|
|
||||||
lines.push_back(current_line);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t LayoutEngine::get_element_fg_color(ElementType type) const {
|
|
||||||
switch (type) {
|
|
||||||
case ElementType::HEADING1:
|
|
||||||
return colors::H1_FG;
|
|
||||||
case ElementType::HEADING2:
|
|
||||||
return colors::H2_FG;
|
|
||||||
case ElementType::HEADING3:
|
|
||||||
case ElementType::HEADING4:
|
|
||||||
case ElementType::HEADING5:
|
|
||||||
case ElementType::HEADING6:
|
|
||||||
return colors::H3_FG;
|
|
||||||
case ElementType::LINK:
|
|
||||||
return colors::LINK_FG;
|
|
||||||
case ElementType::CODE_BLOCK:
|
|
||||||
return colors::CODE_FG;
|
|
||||||
case ElementType::BLOCKQUOTE:
|
|
||||||
return colors::QUOTE_FG;
|
|
||||||
default:
|
|
||||||
return colors::FG_PRIMARY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uint8_t LayoutEngine::get_element_attrs(ElementType type) const {
|
|
||||||
switch (type) {
|
|
||||||
case ElementType::HEADING1:
|
|
||||||
case ElementType::HEADING2:
|
|
||||||
case ElementType::HEADING3:
|
|
||||||
case ElementType::HEADING4:
|
|
||||||
case ElementType::HEADING5:
|
|
||||||
case ElementType::HEADING6:
|
|
||||||
return ATTR_BOLD;
|
|
||||||
case ElementType::LINK:
|
|
||||||
return ATTR_UNDERLINE;
|
|
||||||
default:
|
|
||||||
return ATTR_NONE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string LayoutEngine::get_list_marker(int depth, bool ordered, int counter) const {
|
|
||||||
if (ordered) {
|
|
||||||
return std::to_string(counter) + ". ";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不同层级使用不同的标记
|
|
||||||
switch ((depth - 1) % 3) {
|
|
||||||
case 0: return std::string(chars::BULLET) + " ";
|
|
||||||
case 1: return std::string(chars::BULLET_HOLLOW) + " ";
|
|
||||||
case 2: return std::string(chars::BULLET_SQUARE) + " ";
|
|
||||||
default: return std::string(chars::BULLET) + " ";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== DocumentRenderer ====================
|
|
||||||
|
|
||||||
DocumentRenderer::DocumentRenderer(FrameBuffer& buffer)
|
|
||||||
: buffer_(buffer)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
void DocumentRenderer::render(const LayoutResult& layout, int scroll_offset, const RenderContext& ctx) {
|
|
||||||
int buffer_height = buffer_.height();
|
|
||||||
int y = 0; // 缓冲区行位置
|
|
||||||
int doc_line = 0; // 文档行位置
|
|
||||||
|
|
||||||
for (const auto& block : layout.blocks) {
|
|
||||||
// 处理上边距
|
|
||||||
for (int i = 0; i < block.margin_top; ++i) {
|
|
||||||
if (doc_line >= scroll_offset && y < buffer_height) {
|
|
||||||
// 空行
|
|
||||||
y++;
|
|
||||||
}
|
|
||||||
doc_line++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染内容行
|
|
||||||
for (const auto& line : block.lines) {
|
|
||||||
if (doc_line >= scroll_offset) {
|
|
||||||
if (y >= buffer_height) {
|
|
||||||
return; // 超出视口
|
|
||||||
}
|
|
||||||
render_line(line, y, doc_line, ctx);
|
|
||||||
y++;
|
|
||||||
}
|
|
||||||
doc_line++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理下边距
|
|
||||||
for (int i = 0; i < block.margin_bottom; ++i) {
|
|
||||||
if (doc_line >= scroll_offset && y < buffer_height) {
|
|
||||||
// 空行
|
|
||||||
y++;
|
|
||||||
}
|
|
||||||
doc_line++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int DocumentRenderer::find_match_at(const SearchContext* search, int doc_line, int col) const {
|
|
||||||
if (!search || !search->enabled || search->matches.empty()) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (size_t i = 0; i < search->matches.size(); ++i) {
|
|
||||||
const auto& m = search->matches[i];
|
|
||||||
if (m.line == doc_line && col >= m.start_col && col < m.start_col + m.length) {
|
|
||||||
return static_cast<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,410 +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() {
|
|
||||||
::clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
void refresh() {
|
|
||||||
::refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== True Color ====================
|
|
||||||
|
|
||||||
void set_foreground(uint32_t rgb) {
|
|
||||||
if (has_true_color_) {
|
|
||||||
// ANSI escape: ESC[38;2;R;G;Bm
|
|
||||||
int r = (rgb >> 16) & 0xFF;
|
|
||||||
int g = (rgb >> 8) & 0xFF;
|
|
||||||
int b = rgb & 0xFF;
|
|
||||||
std::printf("\033[38;2;%d;%d;%dm", r, g, b);
|
|
||||||
std::fflush(stdout);
|
|
||||||
} else {
|
|
||||||
// 降级到基础色(简化映射)
|
|
||||||
// 这里可以实现256色或8色的映射
|
|
||||||
// 暂时使用默认色
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_background(uint32_t rgb) {
|
|
||||||
if (has_true_color_) {
|
|
||||||
// ANSI escape: ESC[48;2;R;G;Bm
|
|
||||||
int r = (rgb >> 16) & 0xFF;
|
|
||||||
int g = (rgb >> 8) & 0xFF;
|
|
||||||
int b = rgb & 0xFF;
|
|
||||||
std::printf("\033[48;2;%d;%d;%dm", r, g, b);
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset_colors() {
|
|
||||||
// ESC[39m 重置前景色, ESC[49m 重置背景色
|
|
||||||
std::printf("\033[39m\033[49m");
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 文本属性 ====================
|
|
||||||
|
|
||||||
void set_bold(bool enabled) {
|
|
||||||
if (enabled) {
|
|
||||||
std::printf("\033[1m"); // ESC[1m
|
|
||||||
} else {
|
|
||||||
std::printf("\033[22m"); // ESC[22m (normal intensity)
|
|
||||||
}
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_italic(bool enabled) {
|
|
||||||
if (!has_italic_) return;
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
std::printf("\033[3m"); // ESC[3m
|
|
||||||
} else {
|
|
||||||
std::printf("\033[23m"); // ESC[23m
|
|
||||||
}
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_underline(bool enabled) {
|
|
||||||
if (enabled) {
|
|
||||||
std::printf("\033[4m"); // ESC[4m
|
|
||||||
} else {
|
|
||||||
std::printf("\033[24m"); // ESC[24m
|
|
||||||
}
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_reverse(bool enabled) {
|
|
||||||
if (enabled) {
|
|
||||||
std::printf("\033[7m"); // ESC[7m
|
|
||||||
} else {
|
|
||||||
std::printf("\033[27m"); // ESC[27m
|
|
||||||
}
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
void set_dim(bool enabled) {
|
|
||||||
if (enabled) {
|
|
||||||
std::printf("\033[2m"); // ESC[2m
|
|
||||||
} else {
|
|
||||||
std::printf("\033[22m"); // ESC[22m
|
|
||||||
}
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset_attributes() {
|
|
||||||
std::printf("\033[0m"); // ESC[0m (reset all)
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 光标控制 ====================
|
|
||||||
|
|
||||||
void move_cursor(int x, int y) {
|
|
||||||
move(y, x); // ncurses使用 (y, x) 顺序
|
|
||||||
}
|
|
||||||
|
|
||||||
void hide_cursor() {
|
|
||||||
curs_set(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void show_cursor() {
|
|
||||||
curs_set(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 文本输出 ====================
|
|
||||||
|
|
||||||
void print(const std::string& text) {
|
|
||||||
// 直接输出到stdout(配合ANSI escape sequences)
|
|
||||||
std::printf("%s", text.c_str());
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
void print_at(int x, int y, const std::string& text) {
|
|
||||||
move_cursor(x, y);
|
|
||||||
print(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 输入处理 ====================
|
|
||||||
|
|
||||||
int get_key(int timeout_ms) {
|
|
||||||
if (timeout_ms == -1) {
|
|
||||||
// 阻塞等待
|
|
||||||
nodelay(stdscr, FALSE);
|
|
||||||
int ch = getch();
|
|
||||||
nodelay(stdscr, TRUE);
|
|
||||||
return ch;
|
|
||||||
} else if (timeout_ms == 0) {
|
|
||||||
// 非阻塞
|
|
||||||
return getch();
|
|
||||||
} else {
|
|
||||||
// 超时等待
|
|
||||||
timeout(timeout_ms);
|
|
||||||
int ch = getch();
|
|
||||||
nodelay(stdscr, TRUE);
|
|
||||||
return ch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get_mouse_event(MouseEvent& event) {
|
|
||||||
if (!mouse_enabled_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
MEVENT mevent;
|
|
||||||
int ch = getch();
|
|
||||||
|
|
||||||
if (ch == KEY_MOUSE) {
|
|
||||||
if (getmouse(&mevent) == OK) {
|
|
||||||
event.x = mevent.x;
|
|
||||||
event.y = mevent.y;
|
|
||||||
|
|
||||||
// 解析鼠标事件类型
|
|
||||||
if (mevent.bstate & BUTTON1_CLICKED) {
|
|
||||||
event.type = MouseEvent::Type::CLICK;
|
|
||||||
event.button = 0;
|
|
||||||
return true;
|
|
||||||
} else if (mevent.bstate & BUTTON2_CLICKED) {
|
|
||||||
event.type = MouseEvent::Type::CLICK;
|
|
||||||
event.button = 1;
|
|
||||||
return true;
|
|
||||||
} else if (mevent.bstate & BUTTON3_CLICKED) {
|
|
||||||
event.type = MouseEvent::Type::CLICK;
|
|
||||||
event.button = 2;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#ifdef BUTTON4_PRESSED
|
|
||||||
else if (mevent.bstate & BUTTON4_PRESSED) {
|
|
||||||
event.type = MouseEvent::Type::SCROLL_UP;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#ifdef BUTTON5_PRESSED
|
|
||||||
else if (mevent.bstate & BUTTON5_PRESSED) {
|
|
||||||
event.type = MouseEvent::Type::SCROLL_DOWN;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 终端能力 ====================
|
|
||||||
|
|
||||||
bool supports_true_color() const { return has_true_color_; }
|
|
||||||
bool supports_mouse() const { return has_mouse_; }
|
|
||||||
bool supports_unicode() const { return has_unicode_; }
|
|
||||||
bool supports_italic() const { return has_italic_; }
|
|
||||||
|
|
||||||
// ==================== 高级功能 ====================
|
|
||||||
|
|
||||||
void enable_mouse(bool enabled) {
|
|
||||||
if (enabled) {
|
|
||||||
// 启用所有鼠标事件
|
|
||||||
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, nullptr);
|
|
||||||
// 发送启用鼠标跟踪的ANSI序列
|
|
||||||
std::printf("\033[?1003h"); // 启用所有鼠标事件
|
|
||||||
std::fflush(stdout);
|
|
||||||
mouse_enabled_ = true;
|
|
||||||
} else {
|
|
||||||
mousemask(0, nullptr);
|
|
||||||
std::printf("\033[?1003l"); // 禁用鼠标跟踪
|
|
||||||
std::fflush(stdout);
|
|
||||||
mouse_enabled_ = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void use_alternate_screen(bool enabled) {
|
|
||||||
if (enabled) {
|
|
||||||
std::printf("\033[?1049h"); // 进入替代屏幕
|
|
||||||
} else {
|
|
||||||
std::printf("\033[?1049l"); // 退出替代屏幕
|
|
||||||
}
|
|
||||||
std::fflush(stdout);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
bool initialized_;
|
|
||||||
bool has_true_color_;
|
|
||||||
bool has_mouse_;
|
|
||||||
bool has_unicode_;
|
|
||||||
bool has_italic_;
|
|
||||||
int width_;
|
|
||||||
int height_;
|
|
||||||
bool mouse_enabled_;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== Terminal 公共接口 ====================
|
|
||||||
|
|
||||||
Terminal::Terminal() : pImpl(std::make_unique<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,622 +1,260 @@
|
||||||
#include "text_renderer.h"
|
#include "text_renderer.h"
|
||||||
#include "dom_tree.h"
|
|
||||||
#include <algorithm>
|
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <cstring>
|
#include <algorithm>
|
||||||
#include <cwchar>
|
#include <clocale>
|
||||||
#include <vector>
|
|
||||||
#include <cmath>
|
|
||||||
#include <numeric>
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Helper Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
// Calculate display width of UTF-8 string (handling CJK characters)
|
|
||||||
size_t display_width(const std::string& str) {
|
|
||||||
size_t width = 0;
|
|
||||||
for (size_t i = 0; i < str.length(); ) {
|
|
||||||
unsigned char c = str[i];
|
|
||||||
|
|
||||||
if (c < 0x80) {
|
|
||||||
// ASCII
|
|
||||||
width += 1;
|
|
||||||
i += 1;
|
|
||||||
} else if ((c & 0xE0) == 0xC0) {
|
|
||||||
// 2-byte UTF-8
|
|
||||||
width += 1;
|
|
||||||
i += 2;
|
|
||||||
} else if ((c & 0xF0) == 0xE0) {
|
|
||||||
// 3-byte UTF-8 (likely CJK)
|
|
||||||
width += 2;
|
|
||||||
i += 3;
|
|
||||||
} else if ((c & 0xF8) == 0xF0) {
|
|
||||||
// 4-byte UTF-8
|
|
||||||
width += 2;
|
|
||||||
i += 4;
|
|
||||||
} else {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pad string to specific visual width
|
|
||||||
std::string pad_string(const std::string& str, size_t target_width) {
|
|
||||||
size_t current_width = display_width(str);
|
|
||||||
if (current_width >= target_width) return str;
|
|
||||||
return str + std::string(target_width - current_width, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean whitespace
|
|
||||||
std::string clean_text(const std::string& text) {
|
|
||||||
std::string result;
|
|
||||||
bool in_space = false;
|
|
||||||
|
|
||||||
for (char c : text) {
|
|
||||||
if (std::isspace(c)) {
|
|
||||||
if (!in_space) {
|
|
||||||
result += ' ';
|
|
||||||
in_space = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result += c;
|
|
||||||
in_space = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t start = result.find_first_not_of(" \t\n\r");
|
|
||||||
if (start == std::string::npos) return "";
|
|
||||||
|
|
||||||
size_t end = result.find_last_not_of(" \t\n\r");
|
|
||||||
return result.substr(start, end - start + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LinkInfo {
|
|
||||||
size_t start_pos;
|
|
||||||
size_t end_pos;
|
|
||||||
int link_index;
|
|
||||||
int field_index;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Text wrapping with link preservation
|
|
||||||
std::vector<std::pair<std::string, std::vector<LinkInfo>>> wrap_text_with_links(
|
|
||||||
const std::string& text,
|
|
||||||
int max_width,
|
|
||||||
const std::vector<InlineLink>& links
|
|
||||||
) {
|
|
||||||
std::vector<std::pair<std::string, std::vector<LinkInfo>>> result;
|
|
||||||
if (max_width <= 0) return result;
|
|
||||||
|
|
||||||
// 1. Insert [N] markers for links (form fields don't get [N])
|
|
||||||
std::string marked_text;
|
|
||||||
std::vector<LinkInfo> adjusted_links;
|
|
||||||
size_t pos = 0;
|
|
||||||
|
|
||||||
for (const auto& link : links) {
|
|
||||||
marked_text += text.substr(pos, link.start_pos - pos);
|
|
||||||
size_t link_start = marked_text.length();
|
|
||||||
|
|
||||||
marked_text += text.substr(link.start_pos, link.end_pos - link.start_pos);
|
|
||||||
|
|
||||||
// Add marker [N] only for links
|
|
||||||
if (link.link_index >= 0) {
|
|
||||||
std::string marker = "[" + std::to_string(link.link_index + 1) + "]";
|
|
||||||
marked_text += marker;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t link_end = marked_text.length();
|
|
||||||
|
|
||||||
adjusted_links.push_back({link_start, link_end, link.link_index, link.field_index});
|
|
||||||
pos = link.end_pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pos < text.length()) {
|
|
||||||
marked_text += text.substr(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Wrap text
|
|
||||||
size_t line_start_idx = 0;
|
|
||||||
size_t current_line_width = 0;
|
|
||||||
size_t last_space_idx = std::string::npos;
|
|
||||||
|
|
||||||
for (size_t i = 0; i <= marked_text.length(); ++i) {
|
|
||||||
bool is_break = (i == marked_text.length() || marked_text[i] == ' ' || marked_text[i] == '\n');
|
|
||||||
|
|
||||||
if (is_break) {
|
|
||||||
std::string word = marked_text.substr(
|
|
||||||
(last_space_idx == std::string::npos) ? line_start_idx : last_space_idx + 1,
|
|
||||||
i - ((last_space_idx == std::string::npos) ? line_start_idx : last_space_idx + 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
size_t word_width = display_width(word);
|
|
||||||
size_t space_width = (current_line_width == 0) ? 0 : 1;
|
|
||||||
|
|
||||||
if (current_line_width + space_width + word_width > static_cast<size_t>(max_width)) {
|
|
||||||
// Wrap
|
|
||||||
if (current_line_width > 0) {
|
|
||||||
// End current line at last space
|
|
||||||
std::string line_str = marked_text.substr(line_start_idx, last_space_idx - line_start_idx);
|
|
||||||
|
|
||||||
// Collect links
|
|
||||||
std::vector<LinkInfo> line_links;
|
|
||||||
for (const auto& link : adjusted_links) {
|
|
||||||
// Check overlap
|
|
||||||
size_t link_s = link.start_pos;
|
|
||||||
size_t link_e = link.end_pos;
|
|
||||||
size_t line_s = line_start_idx;
|
|
||||||
size_t line_e = last_space_idx;
|
|
||||||
|
|
||||||
if (link_s < line_e && link_e > line_s) {
|
|
||||||
size_t start = (link_s > line_s) ? link_s - line_s : 0;
|
|
||||||
size_t end = (link_e < line_e) ? link_e - line_s : line_e - line_s;
|
|
||||||
line_links.push_back({start, end, link.link_index, link.field_index});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.push_back({line_str, line_links});
|
|
||||||
|
|
||||||
// Start new line
|
|
||||||
line_start_idx = last_space_idx + 1;
|
|
||||||
current_line_width = word_width;
|
|
||||||
last_space_idx = i;
|
|
||||||
} else {
|
|
||||||
// Word itself is too long, force break (not implemented for simplicity, just overflow)
|
|
||||||
last_space_idx = i;
|
|
||||||
current_line_width += space_width + word_width;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
current_line_width += space_width + word_width;
|
|
||||||
last_space_idx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last line
|
|
||||||
if (line_start_idx < marked_text.length()) {
|
|
||||||
std::string line_str = marked_text.substr(line_start_idx);
|
|
||||||
std::vector<LinkInfo> line_links;
|
|
||||||
for (const auto& link : adjusted_links) {
|
|
||||||
size_t link_s = link.start_pos;
|
|
||||||
size_t link_e = link.end_pos;
|
|
||||||
size_t line_s = line_start_idx;
|
|
||||||
size_t line_e = marked_text.length();
|
|
||||||
|
|
||||||
if (link_s < line_e && link_e > line_s) {
|
|
||||||
size_t start = (link_s > line_s) ? link_s - line_s : 0;
|
|
||||||
size_t end = (link_e < line_e) ? link_e - line_s : line_e - line_s;
|
|
||||||
line_links.push_back({start, end, link.link_index, link.field_index});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.push_back({line_str, line_links});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TextRenderer::Impl
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
class TextRenderer::Impl {
|
class TextRenderer::Impl {
|
||||||
public:
|
public:
|
||||||
RenderConfig config;
|
RenderConfig config;
|
||||||
|
|
||||||
struct InlineContent {
|
std::vector<std::string> wrap_text(const std::string& text, int width) {
|
||||||
std::string text;
|
std::vector<std::string> lines;
|
||||||
std::vector<InlineLink> links;
|
if (text.empty()) {
|
||||||
};
|
|
||||||
|
|
||||||
RenderedLine create_empty_line() {
|
|
||||||
RenderedLine line;
|
|
||||||
line.text = "";
|
|
||||||
line.color_pair = COLOR_NORMAL;
|
|
||||||
line.is_bold = false;
|
|
||||||
line.is_link = false;
|
|
||||||
line.link_index = -1;
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::vector<RenderedLine> render_tree(const DocumentTree& tree, int screen_width) {
|
|
||||||
std::vector<RenderedLine> lines;
|
|
||||||
if (!tree.root) return lines;
|
|
||||||
|
|
||||||
RenderContext ctx;
|
|
||||||
ctx.screen_width = config.center_content ? std::min(config.max_width, screen_width) : screen_width;
|
|
||||||
ctx.current_indent = 0;
|
|
||||||
ctx.nesting_level = 0;
|
|
||||||
ctx.color_pair = COLOR_NORMAL;
|
|
||||||
ctx.is_bold = false;
|
|
||||||
|
|
||||||
render_node(tree.root.get(), ctx, lines);
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
void render_node(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
std::istringstream words_stream(text);
|
||||||
if (!node || !node->should_render()) return;
|
std::string word;
|
||||||
|
std::string current_line;
|
||||||
|
|
||||||
if (node->is_block_element()) {
|
while (words_stream >> word) {
|
||||||
if (node->tag_name == "table") {
|
if (word.length() > static_cast<size_t>(width)) {
|
||||||
render_table(node, ctx, lines);
|
if (!current_line.empty()) {
|
||||||
|
lines.push_back(current_line);
|
||||||
|
current_line.clear();
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < word.length(); i += width) {
|
||||||
|
lines.push_back(word.substr(i, width));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current_line.empty()) {
|
||||||
|
current_line = word;
|
||||||
|
} else if (current_line.length() + 1 + word.length() <= static_cast<size_t>(width)) {
|
||||||
|
current_line += " " + word;
|
||||||
} else {
|
} else {
|
||||||
switch (node->element_type) {
|
lines.push_back(current_line);
|
||||||
case ElementType::HEADING1:
|
current_line = word;
|
||||||
case ElementType::HEADING2:
|
|
||||||
case ElementType::HEADING3:
|
|
||||||
render_heading(node, ctx, lines);
|
|
||||||
break;
|
|
||||||
case ElementType::PARAGRAPH:
|
|
||||||
render_paragraph(node, ctx, lines);
|
|
||||||
break;
|
|
||||||
case ElementType::HORIZONTAL_RULE:
|
|
||||||
render_hr(node, ctx, lines);
|
|
||||||
break;
|
|
||||||
case ElementType::CODE_BLOCK:
|
|
||||||
render_code_block(node, ctx, lines);
|
|
||||||
break;
|
|
||||||
case ElementType::BLOCKQUOTE:
|
|
||||||
render_blockquote(node, ctx, lines);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (node->tag_name == "ul" || node->tag_name == "ol") {
|
|
||||||
render_list(node, ctx, lines);
|
|
||||||
} else {
|
|
||||||
for (auto& child : node->children) {
|
|
||||||
render_node(child.get(), ctx, lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (node->node_type == NodeType::DOCUMENT || node->node_type == NodeType::ELEMENT) {
|
|
||||||
for (auto& child : node->children) {
|
|
||||||
render_node(child.get(), ctx, lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Table Rendering
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
struct CellData {
|
|
||||||
std::vector<std::string> lines; // Wrapped lines
|
|
||||||
int width = 0;
|
|
||||||
int height = 0;
|
|
||||||
int colspan = 1;
|
|
||||||
int rowspan = 1;
|
|
||||||
bool is_header = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
void render_table(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
|
||||||
// Simplified table rendering (skipping complex grid for brevity, reverting to previous improved logic)
|
|
||||||
// Note: For brevity in this tool call, reusing the logic from previous step but integrated with form fields?
|
|
||||||
// Actually, let's keep the logic I wrote before.
|
|
||||||
|
|
||||||
// 1. Collect Table Data
|
|
||||||
std::vector<std::vector<CellData>> grid;
|
|
||||||
std::vector<int> col_widths;
|
|
||||||
int max_cols = 0;
|
|
||||||
|
|
||||||
for (auto& child : node->children) {
|
|
||||||
if (child->tag_name == "tr") {
|
|
||||||
std::vector<CellData> row;
|
|
||||||
for (auto& cell : child->children) {
|
|
||||||
if (cell->tag_name == "td" || cell->tag_name == "th") {
|
|
||||||
CellData data;
|
|
||||||
data.is_header = (cell->tag_name == "th");
|
|
||||||
data.colspan = cell->colspan > 0 ? cell->colspan : 1;
|
|
||||||
InlineContent content = collect_inline_content(cell.get());
|
|
||||||
std::string clean = clean_text(content.text);
|
|
||||||
data.lines.push_back(clean);
|
|
||||||
data.width = display_width(clean);
|
|
||||||
data.height = 1;
|
|
||||||
row.push_back(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!row.empty()) {
|
|
||||||
grid.push_back(row);
|
|
||||||
max_cols = std::max(max_cols, (int)row.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grid.empty()) return;
|
|
||||||
|
|
||||||
col_widths.assign(max_cols, 0);
|
|
||||||
for (const auto& row : grid) {
|
|
||||||
for (size_t i = 0; i < row.size(); ++i) {
|
|
||||||
if (i < col_widths.size()) {
|
|
||||||
col_widths[i] = std::max(col_widths[i], row[i].width);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int total_width = std::accumulate(col_widths.begin(), col_widths.end(), 0);
|
|
||||||
int available_width = ctx.screen_width - 4;
|
|
||||||
available_width = std::max(10, available_width);
|
|
||||||
|
|
||||||
if (total_width > available_width) {
|
|
||||||
double ratio = (double)available_width / total_width;
|
|
||||||
for (auto& w : col_widths) {
|
|
||||||
w = std::max(3, (int)(w * ratio));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string border_line = "+";
|
if (!current_line.empty()) {
|
||||||
for (int w : col_widths) {
|
lines.push_back(current_line);
|
||||||
border_line += std::string(w + 2, '-') + "+";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderedLine border;
|
if (lines.empty()) {
|
||||||
border.text = border_line;
|
lines.push_back("");
|
||||||
border.color_pair = COLOR_DIM;
|
|
||||||
lines.push_back(border);
|
|
||||||
|
|
||||||
for (auto& row : grid) {
|
|
||||||
int max_row_height = 0;
|
|
||||||
std::vector<std::vector<std::string>> row_wrapped_content;
|
|
||||||
|
|
||||||
for (size_t i = 0; i < row.size(); ++i) {
|
|
||||||
if (i >= col_widths.size()) break;
|
|
||||||
|
|
||||||
int cell_w = col_widths[i];
|
|
||||||
std::string raw_text = row[i].lines[0];
|
|
||||||
auto wrapped = wrap_text_with_links(raw_text, cell_w, {}); // Simplified: no links in table for now
|
|
||||||
|
|
||||||
std::vector<std::string> cell_lines;
|
|
||||||
for(auto& p : wrapped) cell_lines.push_back(p.first);
|
|
||||||
if (cell_lines.empty()) cell_lines.push_back("");
|
|
||||||
|
|
||||||
row_wrapped_content.push_back(cell_lines);
|
|
||||||
max_row_height = std::max(max_row_height, (int)cell_lines.size());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int h = 0; h < max_row_height; ++h) {
|
return lines;
|
||||||
std::string line_str = "|";
|
|
||||||
for (size_t i = 0; i < col_widths.size(); ++i) {
|
|
||||||
int w = col_widths[i];
|
|
||||||
std::string content = "";
|
|
||||||
if (i < row_wrapped_content.size() && h < (int)row_wrapped_content[i].size()) {
|
|
||||||
content = row_wrapped_content[i][h];
|
|
||||||
}
|
|
||||||
line_str += " " + pad_string(content, w) + " |";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderedLine rline;
|
std::string add_indent(const std::string& text, int indent) {
|
||||||
rline.text = line_str;
|
return std::string(indent, ' ') + text;
|
||||||
rline.color_pair = COLOR_NORMAL;
|
|
||||||
lines.push_back(rline);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push_back(border);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push_back(create_empty_line());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Other Elements
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
void render_heading(DomNode* node, RenderContext& /*ctx*/, std::vector<RenderedLine>& lines) {
|
|
||||||
InlineContent content = collect_inline_content(node);
|
|
||||||
if (content.text.empty()) return;
|
|
||||||
|
|
||||||
RenderedLine line;
|
|
||||||
line.text = clean_text(content.text);
|
|
||||||
line.color_pair = COLOR_HEADING1;
|
|
||||||
line.is_bold = true;
|
|
||||||
lines.push_back(line);
|
|
||||||
lines.push_back(create_empty_line());
|
|
||||||
}
|
|
||||||
|
|
||||||
void render_paragraph(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
|
||||||
InlineContent content = collect_inline_content(node);
|
|
||||||
std::string text = clean_text(content.text);
|
|
||||||
if (text.empty()) return;
|
|
||||||
|
|
||||||
auto wrapped = wrap_text_with_links(text, ctx.screen_width, content.links);
|
|
||||||
for (const auto& [line_text, link_infos] : wrapped) {
|
|
||||||
RenderedLine line;
|
|
||||||
line.text = line_text;
|
|
||||||
line.color_pair = COLOR_NORMAL;
|
|
||||||
if (!link_infos.empty()) {
|
|
||||||
line.is_link = true; // Kept for compatibility, though we use interactive_ranges
|
|
||||||
line.link_index = -1;
|
|
||||||
|
|
||||||
for (const auto& li : link_infos) {
|
|
||||||
InteractiveRange range;
|
|
||||||
range.start = li.start_pos;
|
|
||||||
range.end = li.end_pos;
|
|
||||||
range.link_index = li.link_index;
|
|
||||||
range.field_index = li.field_index;
|
|
||||||
line.interactive_ranges.push_back(range);
|
|
||||||
|
|
||||||
if (li.link_index >= 0) line.link_index = li.link_index; // Heuristic: set main link index to first link
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push_back(line);
|
|
||||||
}
|
|
||||||
lines.push_back(create_empty_line());
|
|
||||||
}
|
|
||||||
|
|
||||||
void render_list(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
|
||||||
bool is_ordered = (node->tag_name == "ol");
|
|
||||||
int count = 1;
|
|
||||||
|
|
||||||
for(auto& child : node->children) {
|
|
||||||
if(child->tag_name == "li") {
|
|
||||||
InlineContent content = collect_inline_content(child.get());
|
|
||||||
std::string prefix = is_ordered ? std::to_string(count++) + ". " : "* ";
|
|
||||||
|
|
||||||
auto wrapped = wrap_text_with_links(clean_text(content.text), ctx.screen_width - 4, content.links);
|
|
||||||
|
|
||||||
bool first = true;
|
|
||||||
for(const auto& [txt, links_info] : wrapped) {
|
|
||||||
RenderedLine line;
|
|
||||||
line.text = (first ? prefix : " ") + txt;
|
|
||||||
line.color_pair = COLOR_NORMAL;
|
|
||||||
|
|
||||||
if(!links_info.empty()) {
|
|
||||||
line.is_link = true;
|
|
||||||
for(const auto& l : links_info) {
|
|
||||||
InteractiveRange range;
|
|
||||||
range.start = l.start_pos + prefix.length();
|
|
||||||
range.end = l.end_pos + prefix.length();
|
|
||||||
range.link_index = l.link_index;
|
|
||||||
range.field_index = l.field_index;
|
|
||||||
line.interactive_ranges.push_back(range);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push_back(line);
|
|
||||||
first = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push_back(create_empty_line());
|
|
||||||
}
|
|
||||||
|
|
||||||
void render_hr(DomNode* /*node*/, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
|
||||||
RenderedLine line;
|
|
||||||
line.text = std::string(ctx.screen_width, '-');
|
|
||||||
line.color_pair = COLOR_DIM;
|
|
||||||
lines.push_back(line);
|
|
||||||
lines.push_back(create_empty_line());
|
|
||||||
}
|
|
||||||
|
|
||||||
void render_code_block(DomNode* node, RenderContext& /*ctx*/, std::vector<RenderedLine>& lines) {
|
|
||||||
std::string text = node->get_all_text();
|
|
||||||
std::istringstream iss(text);
|
|
||||||
std::string line_str;
|
|
||||||
while(std::getline(iss, line_str)) {
|
|
||||||
RenderedLine line;
|
|
||||||
line.text = " " + line_str;
|
|
||||||
line.color_pair = COLOR_DIM;
|
|
||||||
lines.push_back(line);
|
|
||||||
}
|
|
||||||
lines.push_back(create_empty_line());
|
|
||||||
}
|
|
||||||
|
|
||||||
void render_blockquote(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
|
||||||
for (auto& child : node->children) {
|
|
||||||
render_node(child.get(), ctx, lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Collect Inline Content
|
|
||||||
InlineContent collect_inline_content(DomNode* node) {
|
|
||||||
InlineContent result;
|
|
||||||
for (auto& child : node->children) {
|
|
||||||
if (child->node_type == NodeType::TEXT) {
|
|
||||||
result.text += child->text_content;
|
|
||||||
} else if (child->element_type == ElementType::LINK && child->link_index >= 0) {
|
|
||||||
InlineLink link;
|
|
||||||
link.text = child->get_all_text();
|
|
||||||
link.url = child->href;
|
|
||||||
link.link_index = child->link_index;
|
|
||||||
link.field_index = -1;
|
|
||||||
link.start_pos = result.text.length();
|
|
||||||
result.text += link.text;
|
|
||||||
link.end_pos = result.text.length();
|
|
||||||
result.links.push_back(link);
|
|
||||||
} else if (child->element_type == ElementType::INPUT) {
|
|
||||||
std::string repr;
|
|
||||||
if (child->input_type == "checkbox") {
|
|
||||||
repr = child->checked ? "[x]" : "[ ]";
|
|
||||||
} else if (child->input_type == "radio") {
|
|
||||||
repr = child->checked ? "(*)" : "( )";
|
|
||||||
} else if (child->input_type == "submit" || child->input_type == "button") {
|
|
||||||
repr = "[" + (child->value.empty() ? "Submit" : child->value) + "]";
|
|
||||||
} else {
|
|
||||||
// text, password, etc.
|
|
||||||
std::string val = child->value.empty() ? child->placeholder : child->value;
|
|
||||||
if (val.empty()) val = "________";
|
|
||||||
repr = "[" + val + "]";
|
|
||||||
}
|
|
||||||
|
|
||||||
InlineLink link;
|
|
||||||
link.text = repr;
|
|
||||||
link.link_index = -1;
|
|
||||||
link.field_index = child->field_index;
|
|
||||||
link.start_pos = result.text.length();
|
|
||||||
result.text += repr;
|
|
||||||
link.end_pos = result.text.length();
|
|
||||||
result.links.push_back(link);
|
|
||||||
} else if (child->element_type == ElementType::BUTTON) {
|
|
||||||
std::string repr = "[" + (child->value.empty() ? (child->name.empty() ? "Button" : child->name) : child->value) + "]";
|
|
||||||
InlineLink link;
|
|
||||||
link.text = repr;
|
|
||||||
link.link_index = -1;
|
|
||||||
link.field_index = child->field_index;
|
|
||||||
link.start_pos = result.text.length();
|
|
||||||
result.text += repr;
|
|
||||||
link.end_pos = result.text.length();
|
|
||||||
result.links.push_back(link);
|
|
||||||
} else if (child->element_type == ElementType::TEXTAREA) {
|
|
||||||
std::string repr = "[ " + (child->value.empty() ? "Textarea" : child->value) + " ]";
|
|
||||||
InlineLink link;
|
|
||||||
link.text = repr;
|
|
||||||
link.link_index = -1;
|
|
||||||
link.field_index = child->field_index;
|
|
||||||
link.start_pos = result.text.length();
|
|
||||||
result.text += repr;
|
|
||||||
link.end_pos = result.text.length();
|
|
||||||
result.links.push_back(link);
|
|
||||||
} else if (child->element_type == ElementType::SELECT) {
|
|
||||||
std::string repr = "[ Select ]"; // Simplified
|
|
||||||
InlineLink link;
|
|
||||||
link.text = repr;
|
|
||||||
link.link_index = -1;
|
|
||||||
link.field_index = child->field_index;
|
|
||||||
link.start_pos = result.text.length();
|
|
||||||
result.text += repr;
|
|
||||||
link.end_pos = result.text.length();
|
|
||||||
result.links.push_back(link);
|
|
||||||
} else if (child->element_type == ElementType::IMAGE) {
|
|
||||||
// Render image placeholder
|
|
||||||
std::string repr = "[IMG";
|
|
||||||
if (!child->alt_text.empty()) {
|
|
||||||
repr += ": " + child->alt_text;
|
|
||||||
}
|
|
||||||
repr += "]";
|
|
||||||
|
|
||||||
result.text += repr;
|
|
||||||
// Images are not necessarily links unless wrapped in <a>.
|
|
||||||
// If wrapped in <a>, the parent processing handles the link range.
|
|
||||||
} else {
|
|
||||||
InlineContent nested = collect_inline_content(child.get());
|
|
||||||
size_t offset = result.text.length();
|
|
||||||
result.text += nested.text;
|
|
||||||
for(auto l : nested.links) {
|
|
||||||
l.start_pos += offset;
|
|
||||||
l.end_pos += offset;
|
|
||||||
result.links.push_back(l);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy support
|
|
||||||
std::vector<RenderedLine> render_legacy(const ParsedDocument& /*doc*/, int /*screen_width*/) {
|
|
||||||
return {}; // Not used anymore
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
TextRenderer::TextRenderer() : pImpl(std::make_unique<Impl>()) {
|
||||||
// Public Interface
|
pImpl->config = RenderConfig();
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
TextRenderer::TextRenderer() : pImpl(std::make_unique<Impl>()) {}
|
|
||||||
TextRenderer::~TextRenderer() = default;
|
|
||||||
|
|
||||||
std::vector<RenderedLine> TextRenderer::render_tree(const DocumentTree& tree, int screen_width) {
|
|
||||||
return pImpl->render_tree(tree, screen_width);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextRenderer::~TextRenderer() = default;
|
||||||
|
|
||||||
std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int screen_width) {
|
std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int screen_width) {
|
||||||
return pImpl->render_legacy(doc, screen_width);
|
std::vector<RenderedLine> lines;
|
||||||
|
|
||||||
|
int content_width = std::min(pImpl->config.max_width, screen_width - 4);
|
||||||
|
if (content_width < 40) {
|
||||||
|
content_width = screen_width - 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
int margin = 0;
|
||||||
|
if (pImpl->config.center_content && content_width < screen_width) {
|
||||||
|
margin = (screen_width - content_width) / 2;
|
||||||
|
}
|
||||||
|
pImpl->config.margin_left = margin;
|
||||||
|
|
||||||
|
if (!doc.title.empty()) {
|
||||||
|
RenderedLine title_line;
|
||||||
|
title_line.text = std::string(margin, ' ') + doc.title;
|
||||||
|
title_line.color_pair = COLOR_HEADING1;
|
||||||
|
title_line.is_bold = true;
|
||||||
|
title_line.is_link = false;
|
||||||
|
title_line.link_index = -1;
|
||||||
|
lines.push_back(title_line);
|
||||||
|
|
||||||
|
RenderedLine underline;
|
||||||
|
underline.text = std::string(margin, ' ') + std::string(std::min((int)doc.title.length(), content_width), '=');
|
||||||
|
underline.color_pair = COLOR_HEADING1;
|
||||||
|
underline.is_bold = false;
|
||||||
|
underline.is_link = false;
|
||||||
|
underline.link_index = -1;
|
||||||
|
lines.push_back(underline);
|
||||||
|
|
||||||
|
RenderedLine empty;
|
||||||
|
empty.text = "";
|
||||||
|
empty.color_pair = COLOR_NORMAL;
|
||||||
|
empty.is_bold = false;
|
||||||
|
empty.is_link = false;
|
||||||
|
empty.link_index = -1;
|
||||||
|
lines.push_back(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.url.empty()) {
|
||||||
|
RenderedLine url_line;
|
||||||
|
url_line.text = std::string(margin, ' ') + "URL: " + doc.url;
|
||||||
|
url_line.color_pair = COLOR_URL_BAR;
|
||||||
|
url_line.is_bold = false;
|
||||||
|
url_line.is_link = false;
|
||||||
|
url_line.link_index = -1;
|
||||||
|
lines.push_back(url_line);
|
||||||
|
|
||||||
|
RenderedLine empty;
|
||||||
|
empty.text = "";
|
||||||
|
empty.color_pair = COLOR_NORMAL;
|
||||||
|
empty.is_bold = false;
|
||||||
|
empty.is_link = false;
|
||||||
|
empty.link_index = -1;
|
||||||
|
lines.push_back(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& elem : doc.elements) {
|
||||||
|
int color = COLOR_NORMAL;
|
||||||
|
bool bold = false;
|
||||||
|
std::string prefix = "";
|
||||||
|
|
||||||
|
switch (elem.type) {
|
||||||
|
case ElementType::HEADING1:
|
||||||
|
color = COLOR_HEADING1;
|
||||||
|
bold = true;
|
||||||
|
prefix = "# ";
|
||||||
|
break;
|
||||||
|
case ElementType::HEADING2:
|
||||||
|
color = COLOR_HEADING2;
|
||||||
|
bold = true;
|
||||||
|
prefix = "## ";
|
||||||
|
break;
|
||||||
|
case ElementType::HEADING3:
|
||||||
|
color = COLOR_HEADING3;
|
||||||
|
bold = true;
|
||||||
|
prefix = "### ";
|
||||||
|
break;
|
||||||
|
case ElementType::PARAGRAPH:
|
||||||
|
color = COLOR_NORMAL;
|
||||||
|
bold = false;
|
||||||
|
break;
|
||||||
|
case ElementType::BLOCKQUOTE:
|
||||||
|
color = COLOR_DIM;
|
||||||
|
prefix = "> ";
|
||||||
|
break;
|
||||||
|
case ElementType::LIST_ITEM:
|
||||||
|
prefix = " • ";
|
||||||
|
break;
|
||||||
|
case ElementType::HORIZONTAL_RULE:
|
||||||
|
{
|
||||||
|
RenderedLine hr;
|
||||||
|
std::string hrline(content_width, '-');
|
||||||
|
hr.text = std::string(margin, ' ') + hrline;
|
||||||
|
hr.color_pair = COLOR_DIM;
|
||||||
|
hr.is_bold = false;
|
||||||
|
hr.is_link = false;
|
||||||
|
hr.link_index = -1;
|
||||||
|
lines.push_back(hr);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto wrapped_lines = pImpl->wrap_text(elem.text, content_width - prefix.length());
|
||||||
|
for (size_t i = 0; i < wrapped_lines.size(); ++i) {
|
||||||
|
RenderedLine line;
|
||||||
|
if (i == 0) {
|
||||||
|
line.text = std::string(margin, ' ') + prefix + wrapped_lines[i];
|
||||||
|
} else {
|
||||||
|
line.text = std::string(margin + prefix.length(), ' ') + wrapped_lines[i];
|
||||||
|
}
|
||||||
|
line.color_pair = color;
|
||||||
|
line.is_bold = bold;
|
||||||
|
line.is_link = false;
|
||||||
|
line.link_index = -1;
|
||||||
|
lines.push_back(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elem.type == ElementType::PARAGRAPH ||
|
||||||
|
elem.type == ElementType::HEADING1 ||
|
||||||
|
elem.type == ElementType::HEADING2 ||
|
||||||
|
elem.type == ElementType::HEADING3) {
|
||||||
|
for (int i = 0; i < pImpl->config.paragraph_spacing; ++i) {
|
||||||
|
RenderedLine empty;
|
||||||
|
empty.text = "";
|
||||||
|
empty.color_pair = COLOR_NORMAL;
|
||||||
|
empty.is_bold = false;
|
||||||
|
empty.is_link = false;
|
||||||
|
empty.link_index = -1;
|
||||||
|
lines.push_back(empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.links.empty() && pImpl->config.show_link_indicators) {
|
||||||
|
RenderedLine separator;
|
||||||
|
std::string sepline(content_width, '-');
|
||||||
|
separator.text = std::string(margin, ' ') + sepline;
|
||||||
|
separator.color_pair = COLOR_DIM;
|
||||||
|
separator.is_bold = false;
|
||||||
|
separator.is_link = false;
|
||||||
|
separator.link_index = -1;
|
||||||
|
lines.push_back(separator);
|
||||||
|
|
||||||
|
RenderedLine links_header;
|
||||||
|
links_header.text = std::string(margin, ' ') + "Links:";
|
||||||
|
links_header.color_pair = COLOR_HEADING3;
|
||||||
|
links_header.is_bold = true;
|
||||||
|
links_header.is_link = false;
|
||||||
|
links_header.link_index = -1;
|
||||||
|
lines.push_back(links_header);
|
||||||
|
|
||||||
|
RenderedLine empty;
|
||||||
|
empty.text = "";
|
||||||
|
empty.color_pair = COLOR_NORMAL;
|
||||||
|
empty.is_bold = false;
|
||||||
|
empty.is_link = false;
|
||||||
|
empty.link_index = -1;
|
||||||
|
lines.push_back(empty);
|
||||||
|
|
||||||
|
for (size_t i = 0; i < doc.links.size(); ++i) {
|
||||||
|
const auto& link = doc.links[i];
|
||||||
|
std::string link_text = "[" + std::to_string(i) + "] " + link.text;
|
||||||
|
|
||||||
|
auto wrapped = pImpl->wrap_text(link_text, content_width - 4);
|
||||||
|
for (size_t j = 0; j < wrapped.size(); ++j) {
|
||||||
|
RenderedLine link_line;
|
||||||
|
link_line.text = std::string(margin + 2, ' ') + wrapped[j];
|
||||||
|
link_line.color_pair = COLOR_LINK;
|
||||||
|
link_line.is_bold = false;
|
||||||
|
link_line.is_link = true;
|
||||||
|
link_line.link_index = i;
|
||||||
|
lines.push_back(link_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto url_wrapped = pImpl->wrap_text(link.url, content_width - 6);
|
||||||
|
for (const auto& url_line_text : url_wrapped) {
|
||||||
|
RenderedLine url_line;
|
||||||
|
url_line.text = std::string(margin + 4, ' ') + "→ " + url_line_text;
|
||||||
|
url_line.color_pair = COLOR_DIM;
|
||||||
|
url_line.is_bold = false;
|
||||||
|
url_line.is_link = false;
|
||||||
|
url_line.link_index = -1;
|
||||||
|
lines.push_back(url_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push_back(empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
void TextRenderer::set_config(const RenderConfig& config) {
|
void TextRenderer::set_config(const RenderConfig& config) {
|
||||||
|
|
@ -628,14 +266,19 @@ RenderConfig TextRenderer::get_config() const {
|
||||||
}
|
}
|
||||||
|
|
||||||
void init_color_scheme() {
|
void init_color_scheme() {
|
||||||
init_pair(COLOR_NORMAL, COLOR_WHITE, COLOR_BLACK);
|
if (has_colors()) {
|
||||||
init_pair(COLOR_HEADING1, COLOR_CYAN, COLOR_BLACK);
|
start_color();
|
||||||
init_pair(COLOR_HEADING2, COLOR_CYAN, COLOR_BLACK);
|
use_default_colors();
|
||||||
init_pair(COLOR_HEADING3, COLOR_CYAN, COLOR_BLACK);
|
|
||||||
init_pair(COLOR_LINK, COLOR_YELLOW, COLOR_BLACK);
|
init_pair(COLOR_NORMAL, COLOR_WHITE, -1);
|
||||||
init_pair(COLOR_LINK_ACTIVE, COLOR_YELLOW, COLOR_BLUE);
|
init_pair(COLOR_HEADING1, COLOR_CYAN, -1);
|
||||||
|
init_pair(COLOR_HEADING2, COLOR_BLUE, -1);
|
||||||
|
init_pair(COLOR_HEADING3, COLOR_MAGENTA, -1);
|
||||||
|
init_pair(COLOR_LINK, COLOR_YELLOW, -1);
|
||||||
|
init_pair(COLOR_LINK_ACTIVE, COLOR_BLACK, COLOR_YELLOW);
|
||||||
init_pair(COLOR_STATUS_BAR, COLOR_BLACK, COLOR_WHITE);
|
init_pair(COLOR_STATUS_BAR, COLOR_BLACK, COLOR_WHITE);
|
||||||
init_pair(COLOR_URL_BAR, COLOR_CYAN, COLOR_BLACK);
|
init_pair(COLOR_URL_BAR, COLOR_GREEN, -1);
|
||||||
init_pair(COLOR_SEARCH_HIGHLIGHT, COLOR_BLACK, COLOR_YELLOW);
|
init_pair(COLOR_SEARCH_HIGHLIGHT, COLOR_BLACK, COLOR_YELLOW);
|
||||||
init_pair(COLOR_DIM, COLOR_WHITE, COLOR_BLACK);
|
init_pair(COLOR_DIM, COLOR_BLACK, -1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,100 +6,20 @@
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <curses.h>
|
#include <curses.h>
|
||||||
|
|
||||||
// Forward declarations
|
|
||||||
struct DocumentTree;
|
|
||||||
struct DomNode;
|
|
||||||
|
|
||||||
struct InteractiveRange {
|
|
||||||
size_t start;
|
|
||||||
size_t end;
|
|
||||||
int link_index = -1;
|
|
||||||
int field_index = -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct RenderedLine {
|
struct RenderedLine {
|
||||||
std::string text;
|
std::string text;
|
||||||
int color_pair;
|
int color_pair;
|
||||||
bool is_bold;
|
bool is_bold;
|
||||||
bool is_link;
|
bool is_link;
|
||||||
int link_index;
|
int link_index;
|
||||||
std::vector<InteractiveRange> interactive_ranges;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unicode装饰字符
|
|
||||||
namespace UnicodeChars {
|
|
||||||
// 框线字符 (Box Drawing)
|
|
||||||
constexpr const char* DBL_HORIZONTAL = "═";
|
|
||||||
constexpr const char* DBL_VERTICAL = "║";
|
|
||||||
constexpr const char* DBL_TOP_LEFT = "╔";
|
|
||||||
constexpr const char* DBL_TOP_RIGHT = "╗";
|
|
||||||
constexpr const char* DBL_BOTTOM_LEFT = "╚";
|
|
||||||
constexpr const char* DBL_BOTTOM_RIGHT = "╝";
|
|
||||||
|
|
||||||
constexpr const char* SGL_HORIZONTAL = "─";
|
|
||||||
constexpr const char* SGL_VERTICAL = "│";
|
|
||||||
constexpr const char* SGL_TOP_LEFT = "┌";
|
|
||||||
constexpr const char* SGL_TOP_RIGHT = "┐";
|
|
||||||
constexpr const char* SGL_BOTTOM_LEFT = "└";
|
|
||||||
constexpr const char* SGL_BOTTOM_RIGHT = "┘";
|
|
||||||
constexpr const char* SGL_CROSS = "┼";
|
|
||||||
constexpr const char* SGL_T_DOWN = "┬";
|
|
||||||
constexpr const char* SGL_T_UP = "┴";
|
|
||||||
constexpr const char* SGL_T_RIGHT = "├";
|
|
||||||
constexpr const char* SGL_T_LEFT = "┤";
|
|
||||||
|
|
||||||
constexpr const char* HEAVY_HORIZONTAL = "━";
|
|
||||||
constexpr const char* HEAVY_VERTICAL = "┃";
|
|
||||||
|
|
||||||
// 列表符号
|
|
||||||
constexpr const char* BULLET = "•";
|
|
||||||
constexpr const char* CIRCLE = "◦";
|
|
||||||
constexpr const char* SQUARE = "▪";
|
|
||||||
constexpr const char* TRIANGLE = "‣";
|
|
||||||
|
|
||||||
// 装饰符号
|
|
||||||
constexpr const char* SECTION = "§";
|
|
||||||
constexpr const char* PARAGRAPH = "¶";
|
|
||||||
constexpr const char* ARROW_RIGHT = "→";
|
|
||||||
constexpr const char* ELLIPSIS = "…";
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RenderConfig {
|
struct RenderConfig {
|
||||||
// 布局设置
|
int max_width = 80;
|
||||||
int max_width = 80; // 最大内容宽度
|
int margin_left = 0;
|
||||||
int margin_left = 0; // 左边距
|
bool center_content = true;
|
||||||
bool center_content = false; // 内容居中
|
int paragraph_spacing = 1;
|
||||||
int paragraph_spacing = 1; // 段落间距
|
bool show_link_indicators = true;
|
||||||
|
|
||||||
// 响应式宽度设置
|
|
||||||
bool responsive_width = true; // 启用响应式宽度
|
|
||||||
int min_width = 60; // 最小内容宽度
|
|
||||||
int max_content_width = 100; // 最大内容宽度
|
|
||||||
int small_screen_threshold = 80; // 小屏阈值
|
|
||||||
int large_screen_threshold = 120;// 大屏阈值
|
|
||||||
|
|
||||||
// 链接设置
|
|
||||||
bool show_link_indicators = false; // 不显示[N]编号
|
|
||||||
bool inline_links = true; // 内联链接(仅颜色)
|
|
||||||
|
|
||||||
// 视觉样式
|
|
||||||
bool use_unicode_boxes = true; // 使用Unicode框线
|
|
||||||
bool use_fancy_bullets = true; // 使用精美列表符号
|
|
||||||
bool show_decorative_lines = true; // 显示装饰线
|
|
||||||
|
|
||||||
// 标题样式
|
|
||||||
bool h1_use_double_border = true; // H1使用双线框
|
|
||||||
bool h2_use_single_border = true; // H2使用单线框
|
|
||||||
bool h3_use_underline = true; // H3使用下划线
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染上下文
|
|
||||||
struct RenderContext {
|
|
||||||
int screen_width; // 终端宽度
|
|
||||||
int current_indent; // 当前缩进级别
|
|
||||||
int nesting_level; // 列表嵌套层级
|
|
||||||
int color_pair; // 当前颜色
|
|
||||||
bool is_bold; // 是否加粗
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class TextRenderer {
|
class TextRenderer {
|
||||||
|
|
@ -107,12 +27,7 @@ public:
|
||||||
TextRenderer();
|
TextRenderer();
|
||||||
~TextRenderer();
|
~TextRenderer();
|
||||||
|
|
||||||
// 新接口:从DOM树渲染
|
|
||||||
std::vector<RenderedLine> render_tree(const DocumentTree& tree, int screen_width);
|
|
||||||
|
|
||||||
// 旧接口:向后兼容
|
|
||||||
std::vector<RenderedLine> render(const ParsedDocument& doc, int screen_width);
|
std::vector<RenderedLine> render(const ParsedDocument& doc, int screen_width);
|
||||||
|
|
||||||
void set_config(const RenderConfig& config);
|
void set_config(const RenderConfig& config);
|
||||||
RenderConfig get_config() const;
|
RenderConfig get_config() const;
|
||||||
|
|
||||||
|
|
|
||||||
598
src/tui_view.cpp
Normal file
598
src/tui_view.cpp
Normal file
|
|
@ -0,0 +1,598 @@
|
||||||
|
#include "tui_view.h"
|
||||||
|
|
||||||
|
#include <curses.h>
|
||||||
|
#include <chrono>
|
||||||
|
#include <clocale>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <thread> // Added this line
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Define color pairs - Modern btop-inspired color scheme
|
||||||
|
enum ColorPairs {
|
||||||
|
NORMAL_TEXT = 1, // Default white text
|
||||||
|
SHADOW_TEXT, // Dim shadow text
|
||||||
|
BANNER_TEXT, // Bright cyan for banners
|
||||||
|
SELECTED_ITEM, // Bright yellow for selected items
|
||||||
|
BORDER_LINE, // Gray borders and boxes
|
||||||
|
SUCCESS_TEXT, // Green for success states
|
||||||
|
WARNING_TEXT, // Orange/yellow for warnings
|
||||||
|
ERROR_TEXT, // Red for errors
|
||||||
|
INFO_TEXT, // Blue for information
|
||||||
|
ACCENT_TEXT, // Magenta for accents
|
||||||
|
DIM_TEXT, // Dimmed secondary text
|
||||||
|
PROGRESS_BAR, // Green progress bars
|
||||||
|
CALENDAR_HEADER, // Calendar header styling
|
||||||
|
EVENT_PAST, // Grayed out past events
|
||||||
|
EVENT_TODAY, // Highlighted today's events
|
||||||
|
EVENT_UPCOMING // Default upcoming events
|
||||||
|
};
|
||||||
|
|
||||||
|
void init_colors() {
|
||||||
|
if (has_colors()) {
|
||||||
|
start_color();
|
||||||
|
use_default_colors(); // Use terminal's default background
|
||||||
|
|
||||||
|
// Modern color scheme inspired by btop
|
||||||
|
init_pair(NORMAL_TEXT, COLOR_WHITE, -1); // White text
|
||||||
|
init_pair(SHADOW_TEXT, COLOR_BLACK, -1); // Black shadow text
|
||||||
|
init_pair(BANNER_TEXT, COLOR_CYAN, -1); // Bright cyan for banners
|
||||||
|
init_pair(SELECTED_ITEM, COLOR_YELLOW, -1); // Bright yellow selection
|
||||||
|
init_pair(BORDER_LINE, COLOR_BLUE, -1); // Blue borders/boxes
|
||||||
|
init_pair(SUCCESS_TEXT, COLOR_GREEN, -1); // Green for success
|
||||||
|
init_pair(WARNING_TEXT, COLOR_YELLOW, -1); // Orange/yellow warnings
|
||||||
|
init_pair(ERROR_TEXT, COLOR_RED, -1); // Red for errors
|
||||||
|
init_pair(INFO_TEXT, COLOR_BLUE, -1); // Blue for info
|
||||||
|
init_pair(ACCENT_TEXT, COLOR_MAGENTA, -1); // Magenta accents
|
||||||
|
init_pair(DIM_TEXT, COLOR_BLACK, -1); // Dimmed text
|
||||||
|
init_pair(PROGRESS_BAR, COLOR_GREEN, -1); // Green progress
|
||||||
|
init_pair(CALENDAR_HEADER, COLOR_CYAN, -1); // Calendar headers
|
||||||
|
init_pair(EVENT_PAST, COLOR_BLACK, -1); // Grayed past events
|
||||||
|
init_pair(EVENT_TODAY, COLOR_YELLOW, -1); // Today's events highlighted
|
||||||
|
init_pair(EVENT_UPCOMING, COLOR_WHITE, -1); // Default upcoming events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to draw a box
|
||||||
|
void draw_box(int start_y, int start_x, int width, int height, bool shadow = false) {
|
||||||
|
if (shadow) {
|
||||||
|
// Draw shadow first
|
||||||
|
attron(COLOR_PAIR(SHADOW_TEXT));
|
||||||
|
for (int i = 0; i < height; i++) {
|
||||||
|
mvprintw(start_y + i + 1, start_x + 1, "%s", std::string(width, ' ').c_str());
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(SHADOW_TEXT));
|
||||||
|
}
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(BORDER_LINE));
|
||||||
|
// Draw corners
|
||||||
|
mvprintw(start_y, start_x, "┌");
|
||||||
|
mvprintw(start_y, start_x + width - 1, "┐");
|
||||||
|
mvprintw(start_y + height - 1, start_x, "└");
|
||||||
|
mvprintw(start_y + height - 1, start_x + width - 1, "┘");
|
||||||
|
|
||||||
|
// Draw horizontal lines
|
||||||
|
for (int i = 1; i < width - 1; i++) {
|
||||||
|
mvprintw(start_y, start_x + i, "─");
|
||||||
|
mvprintw(start_y + height - 1, start_x + i, "─");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw vertical lines
|
||||||
|
for (int i = 1; i < height - 1; i++) {
|
||||||
|
mvprintw(start_y + i, start_x, "│");
|
||||||
|
mvprintw(start_y + i, start_x + width - 1, "│");
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(BORDER_LINE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to draw a progress bar
|
||||||
|
void draw_progress_bar(int y, int x, int width, float percentage) {
|
||||||
|
int filled_width = static_cast<int>(width * percentage);
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(BORDER_LINE));
|
||||||
|
mvprintw(y, x, "[");
|
||||||
|
mvprintw(y, x + width - 1, "]");
|
||||||
|
attroff(COLOR_PAIR(BORDER_LINE));
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(PROGRESS_BAR));
|
||||||
|
for (int i = 1; i < filled_width && i < width - 1; i++) {
|
||||||
|
mvprintw(y, x + i, "█");
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(PROGRESS_BAR));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to center text within a box
|
||||||
|
void draw_centered_text(int y, int box_start_x, int box_width, const std::string& text, int color_pair = NORMAL_TEXT) {
|
||||||
|
int text_x = box_start_x + (box_width - text.length()) / 2;
|
||||||
|
if (text_x < box_start_x) text_x = box_start_x;
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(color_pair));
|
||||||
|
mvprintw(y, text_x, "%s", text.c_str());
|
||||||
|
attroff(COLOR_PAIR(color_pair));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_date(const std::chrono::system_clock::time_point &tp) {
|
||||||
|
auto tt = std::chrono::system_clock::to_time_t(tp);
|
||||||
|
std::tm tm{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
localtime_s(&tm, &tt);
|
||||||
|
#else
|
||||||
|
localtime_r(&tt, &tm);
|
||||||
|
#endif
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::put_time(&tm, "%Y-%m-%d %a %H:%M");
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if event is today, past, or upcoming
|
||||||
|
int get_event_status(const std::chrono::system_clock::time_point &event_time) {
|
||||||
|
auto now = std::chrono::system_clock::now();
|
||||||
|
|
||||||
|
if (event_time < now) {
|
||||||
|
return EVENT_PAST;
|
||||||
|
} else {
|
||||||
|
// Simple check if it's today (within 24 hours)
|
||||||
|
auto hours_until_event = std::chrono::duration_cast<std::chrono::hours>(event_time - now);
|
||||||
|
if (hours_until_event.count() <= 24) {
|
||||||
|
return EVENT_TODAY;
|
||||||
|
} else {
|
||||||
|
return EVENT_UPCOMING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void run_tui(const std::vector<IcsEvent> &events) {
|
||||||
|
// 让 ncurses 按当前终端 locale 处理 UTF-8
|
||||||
|
setlocale(LC_ALL, "");
|
||||||
|
|
||||||
|
initscr();
|
||||||
|
init_colors(); // Initialize colors
|
||||||
|
cbreak();
|
||||||
|
noecho();
|
||||||
|
keypad(stdscr, TRUE);
|
||||||
|
curs_set(0);
|
||||||
|
|
||||||
|
int height, width;
|
||||||
|
getmaxyx(stdscr, height, width);
|
||||||
|
|
||||||
|
// 如果没有任何事件,给出提示信息
|
||||||
|
if (events.empty()) {
|
||||||
|
clear();
|
||||||
|
attron(COLOR_PAIR(BANNER_TEXT));
|
||||||
|
mvprintw(0, 0, "NBTCA 未来一个月活动");
|
||||||
|
attroff(COLOR_PAIR(BANNER_TEXT));
|
||||||
|
mvprintw(2, 0, "未来一个月内暂无活动。");
|
||||||
|
mvprintw(4, 0, "按任意键退出...");
|
||||||
|
refresh();
|
||||||
|
getch();
|
||||||
|
endwin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int top = 0;
|
||||||
|
int selected = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
clear();
|
||||||
|
|
||||||
|
// Modern ASCII art banner for "CALENDAR" - smaller and cleaner
|
||||||
|
std::string calendar_banner[] = {
|
||||||
|
" ╔═══════════════════════════════════╗ ",
|
||||||
|
" ║ [CAL] NBTCA CALENDAR [CAL] ║ ",
|
||||||
|
" ╚═══════════════════════════════════╝ "
|
||||||
|
};
|
||||||
|
|
||||||
|
int banner_height = sizeof(calendar_banner) / sizeof(calendar_banner[0]);
|
||||||
|
int banner_width = calendar_banner[0].length();
|
||||||
|
|
||||||
|
int start_col_banner = (width - banner_width) / 2;
|
||||||
|
if (start_col_banner < 0) start_col_banner = 0;
|
||||||
|
|
||||||
|
// Draw shadow
|
||||||
|
attron(COLOR_PAIR(SHADOW_TEXT));
|
||||||
|
for (int i = 0; i < banner_height; ++i) {
|
||||||
|
mvprintw(i + 1, start_col_banner + 1, "%s", calendar_banner[i].c_str());
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(SHADOW_TEXT));
|
||||||
|
|
||||||
|
// Draw main banner
|
||||||
|
attron(COLOR_PAIR(BANNER_TEXT));
|
||||||
|
for (int i = 0; i < banner_height; ++i) {
|
||||||
|
mvprintw(i, start_col_banner, "%s", calendar_banner[i].c_str());
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(BANNER_TEXT));
|
||||||
|
|
||||||
|
// Draw status bar with current date and event count
|
||||||
|
attron(COLOR_PAIR(BORDER_LINE));
|
||||||
|
mvprintw(banner_height + 1, 0, "┌");
|
||||||
|
mvprintw(banner_height + 1, width - 1, "┐");
|
||||||
|
for (int i = 1; i < width - 1; i++) {
|
||||||
|
mvprintw(banner_height + 1, i, "─");
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(BORDER_LINE));
|
||||||
|
|
||||||
|
// Status information
|
||||||
|
auto now = std::chrono::system_clock::now();
|
||||||
|
auto tt = std::chrono::system_clock::to_time_t(now);
|
||||||
|
std::tm tm{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
localtime_s(&tm, &tt);
|
||||||
|
#else
|
||||||
|
localtime_r(&tt, &tm);
|
||||||
|
#endif
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::put_time(&tm, "%Y-%m-%d %A");
|
||||||
|
std::string current_date = "Today: " + oss.str() + " | Events: " + std::to_string(events.size());
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(INFO_TEXT));
|
||||||
|
mvprintw(banner_height + 1, 2, "%s", current_date.c_str());
|
||||||
|
attroff(COLOR_PAIR(INFO_TEXT));
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(DIM_TEXT));
|
||||||
|
std::string instruction_msg = "[q:Exit ↑↓:Scroll]";
|
||||||
|
mvprintw(banner_height + 1, width - instruction_msg.length() - 2, "%s", instruction_msg.c_str());
|
||||||
|
attroff(COLOR_PAIR(DIM_TEXT));
|
||||||
|
|
||||||
|
int start_event_row = banner_height + 3; // Start events below the banner and instruction
|
||||||
|
int visibleLines = height - start_event_row - 2; // Leave space for bottom border
|
||||||
|
|
||||||
|
// Draw main event container box
|
||||||
|
draw_box(start_event_row - 1, 0, width, visibleLines + 2, true);
|
||||||
|
|
||||||
|
// Header for events list
|
||||||
|
attron(COLOR_PAIR(CALENDAR_HEADER));
|
||||||
|
mvprintw(start_event_row, 2, "╓ Upcoming Events");
|
||||||
|
attroff(COLOR_PAIR(CALENDAR_HEADER));
|
||||||
|
|
||||||
|
int events_start_row = start_event_row + 1;
|
||||||
|
int events_visible_lines = visibleLines - 2;
|
||||||
|
|
||||||
|
if (selected < top) {
|
||||||
|
top = selected;
|
||||||
|
} else if (selected >= top + events_visible_lines) {
|
||||||
|
top = selected - events_visible_lines + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < events_visible_lines; ++i) {
|
||||||
|
int idx = top + i;
|
||||||
|
if (idx >= static_cast<int>(events.size())) break;
|
||||||
|
|
||||||
|
const auto &ev = events[idx];
|
||||||
|
int event_status = get_event_status(ev.start);
|
||||||
|
|
||||||
|
// Event icon based on status
|
||||||
|
std::string icon = "○";
|
||||||
|
if (event_status == EVENT_TODAY) {
|
||||||
|
icon = "*";
|
||||||
|
} else if (event_status == EVENT_PAST) {
|
||||||
|
icon = "v";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date more compactly
|
||||||
|
auto tt = std::chrono::system_clock::to_time_t(ev.start);
|
||||||
|
std::tm tm{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
localtime_s(&tm, &tt);
|
||||||
|
#else
|
||||||
|
localtime_r(&tt, &tm);
|
||||||
|
#endif
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::put_time(&tm, "%m/%d %H:%M");
|
||||||
|
std::string date_str = oss.str();
|
||||||
|
|
||||||
|
// Build the event line
|
||||||
|
std::string line = icon + " " + date_str + " " + ev.summary;
|
||||||
|
if (!ev.location.empty()) {
|
||||||
|
line += " @" + ev.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate if too long
|
||||||
|
if ((int)line.length() > width - 4) {
|
||||||
|
line.resize(width - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine colors based on event status and selection
|
||||||
|
int text_color = (event_status == EVENT_PAST) ? DIM_TEXT :
|
||||||
|
(event_status == EVENT_TODAY) ? EVENT_TODAY :
|
||||||
|
EVENT_UPCOMING;
|
||||||
|
|
||||||
|
if (idx == selected) {
|
||||||
|
attron(A_REVERSE | COLOR_PAIR(SELECTED_ITEM));
|
||||||
|
mvprintw(events_start_row + i, 2, "%s", std::string(width - 4, ' ').c_str());
|
||||||
|
mvprintw(events_start_row + i, 3, "%s", line.c_str());
|
||||||
|
attroff(A_REVERSE | COLOR_PAIR(SELECTED_ITEM));
|
||||||
|
} else {
|
||||||
|
attron(COLOR_PAIR(text_color));
|
||||||
|
mvprintw(events_start_row + i, 3, "%s", line.c_str());
|
||||||
|
attroff(COLOR_PAIR(text_color));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a subtle separator
|
||||||
|
if (i < events_visible_lines - 1 && idx + 1 < static_cast<int>(events.size())) {
|
||||||
|
attron(COLOR_PAIR(BORDER_LINE));
|
||||||
|
mvprintw(events_start_row + i + 1, 2, "├");
|
||||||
|
for (int j = 3; j < width - 2; j++) {
|
||||||
|
mvprintw(events_start_row + i + 1, j, "─");
|
||||||
|
}
|
||||||
|
mvprintw(events_start_row + i + 1, width - 2, "┤");
|
||||||
|
attroff(COLOR_PAIR(BORDER_LINE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add scroll indicator if there are more events
|
||||||
|
if (events.size() > events_visible_lines) {
|
||||||
|
float scroll_percentage = static_cast<float>(top + events_visible_lines) / events.size();
|
||||||
|
int scroll_y = start_event_row + 1;
|
||||||
|
int scroll_x = width - 3;
|
||||||
|
draw_progress_bar(scroll_y, scroll_x, 1, scroll_percentage);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
int ch = getch();
|
||||||
|
if (ch == 'q' || ch == 'Q') {
|
||||||
|
break;
|
||||||
|
} else if (ch == KEY_UP || ch == 'k') {
|
||||||
|
if (selected > 0) selected--;
|
||||||
|
} else if (ch == KEY_DOWN || ch == 'j') {
|
||||||
|
if (selected + 1 < (int)events.size()) selected++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endwin();
|
||||||
|
}
|
||||||
|
|
||||||
|
int run_portal_tui() {
|
||||||
|
setlocale(LC_ALL, "");
|
||||||
|
|
||||||
|
initscr();
|
||||||
|
init_colors(); // Initialize colors
|
||||||
|
cbreak();
|
||||||
|
noecho();
|
||||||
|
keypad(stdscr, TRUE);
|
||||||
|
curs_set(0);
|
||||||
|
|
||||||
|
int height, width;
|
||||||
|
getmaxyx(stdscr, height, width);
|
||||||
|
|
||||||
|
std::vector<std::string> menu_items = {"Calendar", "Exit"};
|
||||||
|
int selected = 0;
|
||||||
|
int choice = -1;
|
||||||
|
|
||||||
|
while (choice == -1) {
|
||||||
|
clear();
|
||||||
|
|
||||||
|
// Modern ASCII art banner for "NBTCA Tools" - smaller and cleaner
|
||||||
|
std::string banner_art[] = {
|
||||||
|
" ╔══════════════════════════════════════╗ ",
|
||||||
|
" ║ [TOOL] NBTCA UTILITY TOOLS [TOOL] ║ ",
|
||||||
|
" ╚══════════════════════════════════════╝ "
|
||||||
|
};
|
||||||
|
|
||||||
|
int banner_height = sizeof(banner_art) / sizeof(banner_art[0]);
|
||||||
|
int banner_width = banner_art[0].length();
|
||||||
|
|
||||||
|
int start_col_banner = (width - banner_width) / 2;
|
||||||
|
if (start_col_banner < 0) start_col_banner = 0;
|
||||||
|
|
||||||
|
// Draw shadow
|
||||||
|
attron(COLOR_PAIR(SHADOW_TEXT));
|
||||||
|
for (int i = 0; i < banner_height; ++i) {
|
||||||
|
mvprintw(i + 1, start_col_banner + 1, "%s", banner_art[i].c_str());
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(SHADOW_TEXT));
|
||||||
|
|
||||||
|
// Draw main banner
|
||||||
|
attron(COLOR_PAIR(BANNER_TEXT));
|
||||||
|
for (int i = 0; i < banner_height; ++i) {
|
||||||
|
mvprintw(i, start_col_banner, "%s", banner_art[i].c_str());
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(BANNER_TEXT));
|
||||||
|
|
||||||
|
// Draw info bar
|
||||||
|
attron(COLOR_PAIR(BORDER_LINE));
|
||||||
|
mvprintw(banner_height + 1, 0, "┌");
|
||||||
|
mvprintw(banner_height + 1, width - 1, "┐");
|
||||||
|
for (int i = 1; i < width - 1; i++) {
|
||||||
|
mvprintw(banner_height + 1, i, "─");
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(BORDER_LINE));
|
||||||
|
|
||||||
|
// Status information
|
||||||
|
auto now = std::chrono::system_clock::now();
|
||||||
|
auto tt = std::chrono::system_clock::to_time_t(now);
|
||||||
|
std::tm tm{};
|
||||||
|
#if defined(_WIN32)
|
||||||
|
localtime_s(&tm, &tt);
|
||||||
|
#else
|
||||||
|
localtime_r(&tt, &tm);
|
||||||
|
#endif
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
|
||||||
|
std::string current_time = "Current: " + oss.str();
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(INFO_TEXT));
|
||||||
|
mvprintw(banner_height + 1, 2, "%s", current_time.c_str());
|
||||||
|
attroff(COLOR_PAIR(INFO_TEXT));
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(DIM_TEXT));
|
||||||
|
std::string help_msg = "[↑↓:Navigate Enter:Select q:Exit]";
|
||||||
|
mvprintw(banner_height + 1, width - help_msg.length() - 2, "%s", help_msg.c_str());
|
||||||
|
attroff(COLOR_PAIR(DIM_TEXT));
|
||||||
|
|
||||||
|
// Draw menu box
|
||||||
|
int menu_box_y = banner_height + 3;
|
||||||
|
int menu_box_height = menu_items.size() + 4;
|
||||||
|
int menu_box_width = 30;
|
||||||
|
int menu_box_x = (width - menu_box_width) / 2;
|
||||||
|
if (menu_box_x < 2) menu_box_x = 2;
|
||||||
|
|
||||||
|
draw_box(menu_box_y, menu_box_x, menu_box_width, menu_box_height, true);
|
||||||
|
|
||||||
|
// Menu box title
|
||||||
|
attron(COLOR_PAIR(CALENDAR_HEADER));
|
||||||
|
draw_centered_text(menu_box_y + 1, menu_box_x, menu_box_width, "Select Module");
|
||||||
|
attroff(COLOR_PAIR(CALENDAR_HEADER));
|
||||||
|
|
||||||
|
// Draw menu items with icons
|
||||||
|
for (size_t i = 0; i < menu_items.size(); ++i) {
|
||||||
|
std::string display_item = menu_items[i];
|
||||||
|
|
||||||
|
// Add icons to menu items
|
||||||
|
if (display_item == "Calendar") {
|
||||||
|
display_item = "[CAL] " + display_item;
|
||||||
|
} else if (display_item == "Exit") {
|
||||||
|
display_item = "[X] " + display_item;
|
||||||
|
}
|
||||||
|
|
||||||
|
int item_y = menu_box_y + 2 + i;
|
||||||
|
|
||||||
|
if ((int)i == selected) {
|
||||||
|
// Draw selection highlight box
|
||||||
|
attron(COLOR_PAIR(BORDER_LINE));
|
||||||
|
mvprintw(item_y, menu_box_x + 1, "│");
|
||||||
|
mvprintw(item_y, menu_box_x + menu_box_width - 2, "│");
|
||||||
|
attroff(COLOR_PAIR(BORDER_LINE));
|
||||||
|
|
||||||
|
attron(A_REVERSE | COLOR_PAIR(SELECTED_ITEM));
|
||||||
|
draw_centered_text(item_y, menu_box_x, menu_box_width, display_item, SELECTED_ITEM);
|
||||||
|
attroff(A_REVERSE | COLOR_PAIR(SELECTED_ITEM));
|
||||||
|
} else {
|
||||||
|
attron(COLOR_PAIR(NORMAL_TEXT));
|
||||||
|
draw_centered_text(item_y, menu_box_x, menu_box_width, display_item);
|
||||||
|
attroff(COLOR_PAIR(NORMAL_TEXT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
int ch = getch();
|
||||||
|
switch (ch) {
|
||||||
|
case KEY_UP:
|
||||||
|
case 'k':
|
||||||
|
if (selected > 0) {
|
||||||
|
selected--;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case KEY_DOWN:
|
||||||
|
case 'j':
|
||||||
|
if (selected < (int)menu_items.size() - 1) {
|
||||||
|
selected++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'q':
|
||||||
|
case 'Q':
|
||||||
|
choice = 1; // Corresponds to "Exit"
|
||||||
|
break;
|
||||||
|
case 10: // Enter key
|
||||||
|
choice = selected;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endwin();
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示启动画面
|
||||||
|
void display_splash_screen() {
|
||||||
|
setlocale(LC_ALL, "");
|
||||||
|
initscr();
|
||||||
|
init_colors(); // Initialize colors
|
||||||
|
cbreak();
|
||||||
|
noecho();
|
||||||
|
curs_set(0);
|
||||||
|
|
||||||
|
int height, width;
|
||||||
|
getmaxyx(stdscr, height, width);
|
||||||
|
|
||||||
|
// Animated splash screen with progress bar
|
||||||
|
for (int frame = 0; frame < 20; ++frame) {
|
||||||
|
clear();
|
||||||
|
|
||||||
|
// Modern ASCII art for "NBTCA Tools" - cleaner design
|
||||||
|
std::string splash_art[] = {
|
||||||
|
" ╔══════════════════════════════════════╗ ",
|
||||||
|
" ║ [TOOL] NBTCA UTILITY TOOLS [TOOL] ║ ",
|
||||||
|
" ╚══════════════════════════════════════╝ "
|
||||||
|
};
|
||||||
|
|
||||||
|
int art_height = sizeof(splash_art) / sizeof(splash_art[0]);
|
||||||
|
int art_width = splash_art[0].length();
|
||||||
|
|
||||||
|
int start_row = (height - art_height) / 2 - 3;
|
||||||
|
int start_col = (width - art_width) / 2;
|
||||||
|
|
||||||
|
if (start_row < 0) start_row = 0;
|
||||||
|
if (start_col < 0) start_col = 0;
|
||||||
|
|
||||||
|
// Draw shadow
|
||||||
|
attron(COLOR_PAIR(SHADOW_TEXT));
|
||||||
|
for (int i = 0; i < art_height; ++i) {
|
||||||
|
mvprintw(start_row + i + 1, start_col + 1, "%s", splash_art[i].c_str());
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(SHADOW_TEXT));
|
||||||
|
|
||||||
|
// Draw main art
|
||||||
|
attron(COLOR_PAIR(BANNER_TEXT));
|
||||||
|
for (int i = 0; i < art_height; ++i) {
|
||||||
|
mvprintw(start_row + i, start_col, "%s", splash_art[i].c_str());
|
||||||
|
}
|
||||||
|
attroff(COLOR_PAIR(BANNER_TEXT));
|
||||||
|
|
||||||
|
// Version info
|
||||||
|
attron(COLOR_PAIR(INFO_TEXT));
|
||||||
|
draw_centered_text(start_row + art_height + 1, start_col, art_width, "Version 0.0.1");
|
||||||
|
attroff(COLOR_PAIR(INFO_TEXT));
|
||||||
|
|
||||||
|
// Loading text with rotating animation
|
||||||
|
const char* spinner[] = {"|", "/", "-", "\\"};
|
||||||
|
std::string loading_msg = std::string(spinner[frame % 4]) + " Initializing system components...";
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(NORMAL_TEXT));
|
||||||
|
draw_centered_text(start_row + art_height + 3, start_col, art_width, loading_msg);
|
||||||
|
attroff(COLOR_PAIR(NORMAL_TEXT));
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
int progress_bar_y = start_row + art_height + 5;
|
||||||
|
int progress_bar_width = 40;
|
||||||
|
int progress_bar_x = (width - progress_bar_width) / 2;
|
||||||
|
|
||||||
|
float progress = static_cast<float>(frame) / 19.0f;
|
||||||
|
draw_progress_bar(progress_bar_y, progress_bar_x, progress_bar_width, progress);
|
||||||
|
|
||||||
|
// Progress percentage
|
||||||
|
attron(COLOR_PAIR(SUCCESS_TEXT));
|
||||||
|
std::string progress_text = std::to_string(static_cast<int>(progress * 100)) + "% Complete";
|
||||||
|
draw_centered_text(progress_bar_y + 1, progress_bar_x, progress_bar_width, progress_text);
|
||||||
|
attroff(COLOR_PAIR(SUCCESS_TEXT));
|
||||||
|
|
||||||
|
// Status messages
|
||||||
|
std::vector<std::string> status_msgs = {
|
||||||
|
"Loading calendar module...",
|
||||||
|
"Initializing network stack...",
|
||||||
|
"Fetching latest events...",
|
||||||
|
"Preparing user interface...",
|
||||||
|
"System ready!"
|
||||||
|
};
|
||||||
|
|
||||||
|
int msg_index = (frame * status_msgs.size()) / 20;
|
||||||
|
if (msg_index >= status_msgs.size()) msg_index = status_msgs.size() - 1;
|
||||||
|
|
||||||
|
attron(COLOR_PAIR(DIM_TEXT));
|
||||||
|
draw_centered_text(progress_bar_y + 2, progress_bar_x, progress_bar_width, status_msgs[msg_index]);
|
||||||
|
attroff(COLOR_PAIR(DIM_TEXT));
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 100ms per frame
|
||||||
|
}
|
||||||
|
|
||||||
|
endwin();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
15
src/tui_view.h
Normal file
15
src/tui_view.h
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "ics_parser.h"
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// 运行 ncurses TUI,展示给定事件列表
|
||||||
|
// 当 events 为空时,在界面上提示“未来一个月暂无活动”
|
||||||
|
void run_tui(const std::vector<IcsEvent> &events);
|
||||||
|
|
||||||
|
// 运行 ncurses TUI for the portal
|
||||||
|
int run_portal_tui();
|
||||||
|
|
||||||
|
// 显示启动画面
|
||||||
|
void display_splash_screen();
|
||||||
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
#include "unicode.h"
|
|
||||||
|
|
||||||
namespace tut {
|
|
||||||
|
|
||||||
size_t Unicode::display_width(const std::string& text) {
|
|
||||||
size_t width = 0;
|
|
||||||
for (size_t i = 0; i < text.length(); ) {
|
|
||||||
unsigned char c = text[i];
|
|
||||||
|
|
||||||
if (c < 0x80) {
|
|
||||||
// ASCII
|
|
||||||
width += 1;
|
|
||||||
i += 1;
|
|
||||||
} else if ((c & 0xE0) == 0xC0) {
|
|
||||||
// 2-byte UTF-8 (e.g., Latin extended)
|
|
||||||
width += 1;
|
|
||||||
i += 2;
|
|
||||||
} else if ((c & 0xF0) == 0xE0) {
|
|
||||||
// 3-byte UTF-8 (CJK characters)
|
|
||||||
width += 2;
|
|
||||||
i += 3;
|
|
||||||
} else if ((c & 0xF8) == 0xF0) {
|
|
||||||
// 4-byte UTF-8 (emoji, rare symbols)
|
|
||||||
width += 2;
|
|
||||||
i += 4;
|
|
||||||
} else {
|
|
||||||
// Invalid UTF-8, skip
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width;
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t Unicode::char_byte_length(const std::string& text, size_t pos) {
|
|
||||||
if (pos >= text.length()) return 0;
|
|
||||||
|
|
||||||
unsigned char c = text[pos];
|
|
||||||
if (c < 0x80) return 1;
|
|
||||||
if ((c & 0xE0) == 0xC0) return 2;
|
|
||||||
if ((c & 0xF0) == 0xE0) return 3;
|
|
||||||
if ((c & 0xF8) == 0xF0) return 4;
|
|
||||||
return 1; // Invalid, treat as single byte
|
|
||||||
}
|
|
||||||
|
|
||||||
size_t Unicode::char_count(const std::string& text) {
|
|
||||||
size_t count = 0;
|
|
||||||
for (size_t i = 0; i < text.length(); ) {
|
|
||||||
i += char_byte_length(text, i);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string Unicode::truncate_to_width(const std::string& text, size_t max_width) {
|
|
||||||
std::string result;
|
|
||||||
size_t current_width = 0;
|
|
||||||
|
|
||||||
for (size_t i = 0; i < text.length(); ) {
|
|
||||||
size_t byte_len = char_byte_length(text, i);
|
|
||||||
unsigned char c = text[i];
|
|
||||||
|
|
||||||
// Calculate width of this character
|
|
||||||
size_t char_width = 1;
|
|
||||||
if ((c & 0xF0) == 0xE0 || (c & 0xF8) == 0xF0) {
|
|
||||||
char_width = 2; // CJK or emoji
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current_width + char_width > max_width) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
result += text.substr(i, byte_len);
|
|
||||||
current_width += char_width;
|
|
||||||
i += byte_len;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string Unicode::pad_to_width(const std::string& text, size_t target_width, char pad_char) {
|
|
||||||
size_t current_width = display_width(text);
|
|
||||||
if (current_width >= target_width) return text;
|
|
||||||
return text + std::string(target_width - current_width, pad_char);
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace tut
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
#pragma once
|
|
||||||
#include <string>
|
|
||||||
#include <cstddef>
|
|
||||||
|
|
||||||
namespace tut {
|
|
||||||
|
|
||||||
class Unicode {
|
|
||||||
public:
|
|
||||||
/**
|
|
||||||
* 计算字符串的显示宽度(考虑CJK、emoji)
|
|
||||||
* ASCII=1, 2-byte=1, 3-byte(CJK)=2, 4-byte(emoji)=2
|
|
||||||
*/
|
|
||||||
static size_t display_width(const std::string& text);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取UTF-8字符的字节长度
|
|
||||||
*/
|
|
||||||
static size_t char_byte_length(const std::string& text, size_t pos);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取字符串中UTF-8字符的数量
|
|
||||||
*/
|
|
||||||
static size_t char_count(const std::string& text);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 截取字符串到指定显示宽度
|
|
||||||
* 返回截取后的字符串,不会截断多字节字符
|
|
||||||
*/
|
|
||||||
static std::string truncate_to_width(const std::string& text, size_t max_width);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 填充字符串到指定显示宽度
|
|
||||||
*/
|
|
||||||
static std::string pad_to_width(const std::string& text, size_t target_width, char pad_char = ' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace tut
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Test Inline Links</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Test Page for Inline Links</h1>
|
|
||||||
|
|
||||||
<p>This is a paragraph with an <a href="https://example.com">inline link</a> in the middle of the text. You should be able to see the link highlighted directly in the text.</p>
|
|
||||||
|
|
||||||
<p>Here is another paragraph with multiple links: <a href="https://google.com">Google</a> and <a href="https://github.com">GitHub</a> are both popular websites.</p>
|
|
||||||
|
|
||||||
<p>This paragraph has a longer link text: <a href="https://en.wikipedia.org">Wikipedia is a free online encyclopedia</a> that anyone can edit.</p>
|
|
||||||
|
|
||||||
<h2>More Examples</h2>
|
|
||||||
|
|
||||||
<p>Press Tab to navigate between links, and Enter to follow them. The links should be <a href="https://example.com/test1">highlighted</a> directly in the text, not listed separately at the bottom.</p>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>List item with <a href="https://news.ycombinator.com">Hacker News</a></li>
|
|
||||||
<li>Another item with <a href="https://reddit.com">Reddit</a></li>
|
|
||||||
</ul>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>POST Form Test</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Form Method Test</h1>
|
|
||||||
|
|
||||||
<h2>GET Form</h2>
|
|
||||||
<form action="https://httpbin.org/get" method="get">
|
|
||||||
<p>Name: <input type="text" name="name" value="John"></p>
|
|
||||||
<p>Email: <input type="text" name="email" value="john@example.com"></p>
|
|
||||||
<p><input type="submit" value="Submit GET"></p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2>POST Form</h2>
|
|
||||||
<form action="https://httpbin.org/post" method="post">
|
|
||||||
<p>Username: <input type="text" name="username" value="testuser"></p>
|
|
||||||
<p>Password: <input type="password" name="password" value="secret123"></p>
|
|
||||||
<p>Message: <input type="text" name="message" value="Hello World"></p>
|
|
||||||
<p><input type="submit" value="Submit POST"></p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<h2>Form with Special Characters</h2>
|
|
||||||
<form action="https://httpbin.org/post" method="post">
|
|
||||||
<p>Text: <input type="text" name="text" value="Hello & goodbye!"></p>
|
|
||||||
<p>Code: <input type="text" name="code" value="a=b&c=d"></p>
|
|
||||||
<p><input type="submit" value="Submit"></p>
|
|
||||||
</form>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<h1>Table Test</h1>
|
|
||||||
<p>This is a paragraph before the table.</p>
|
|
||||||
<table border="1">
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>1</td>
|
|
||||||
<td>Item One</td>
|
|
||||||
<td>This is a long description for item one to test wrapping.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>2</td>
|
|
||||||
<td>Item Two</td>
|
|
||||||
<td>Short desc.</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<p>This is a paragraph after the table.</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -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,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