mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 09:04:04 +00:00
Compare commits
33 commits
v2.0.0-alp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d38cdf93b0 | |||
| 03422136dd | |||
| be6cc4ca44 | |||
| 159e299e96 | |||
| 4aae1fa7dc | |||
| c965472ac5 | |||
| 26109c7ef0 | |||
| 4c33e6c853 | |||
| 335a2561b6 | |||
| 6baa6517ca | |||
| eea499e56e | |||
| fffb3c6756 | |||
| 6408f0e95c | |||
| 70f20a370e | |||
| 45b340798d | |||
| ea56481edb | |||
| b6150bcab0 | |||
| 1233ae52ca | |||
| 63fbee6d30 | |||
| c7c11e08f8 | |||
| 5e2850f7d3 | |||
| 58b7607074 | |||
| 7e55ade793 | |||
| 55fc7c79f5 | |||
| 7ac0fc1c91 | |||
| 8d56a7b67b | |||
| 3f7b627da5 | |||
| 2878b42d36 | |||
| a469f79a1e | |||
| e5276e0b4c | |||
| 18859eef47 | |||
| 584660a518 | |||
| 18f7804145 |
92 changed files with 14590 additions and 8367 deletions
36
.github/workflows/README.md
vendored
Normal file
36
.github/workflows/README.md
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# CI/CD Workflows - TEMPORARILY DISABLED
|
||||
|
||||
## Status: 🔴 Disabled During Active Development
|
||||
|
||||
The CI/CD workflows are currently disabled while we focus on core development.
|
||||
|
||||
### Why Disabled?
|
||||
|
||||
- The browser is in active development (v0.1.0-alpha)
|
||||
- Core UI features are still being implemented
|
||||
- Not ready for public releases yet
|
||||
- Avoiding failed builds during rapid iteration
|
||||
|
||||
### What's Disabled?
|
||||
|
||||
- `release.yml.disabled` - Automated builds and releases
|
||||
|
||||
### When Will It Be Re-enabled?
|
||||
|
||||
Once we reach a stable state with:
|
||||
- ✅ Core browsing functionality (DONE)
|
||||
- ⏳ Interactive link navigation
|
||||
- ⏳ Scrolling
|
||||
- ⏳ Back/forward navigation
|
||||
- ⏳ Basic bookmark/history support
|
||||
|
||||
### To Re-enable
|
||||
|
||||
Simply rename `release.yml.disabled` back to `release.yml`:
|
||||
```bash
|
||||
mv .github/workflows/release.yml.disabled .github/workflows/release.yml
|
||||
```
|
||||
|
||||
### Current Development Focus
|
||||
|
||||
See [STATUS.md](../../STATUS.md) for current development status and roadmap.
|
||||
|
|
@ -26,24 +26,31 @@ jobs:
|
|||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
brew update
|
||||
brew install cmake ncurses curl
|
||||
brew install cmake gumbo-parser openssl ftxui cpp-httplib toml11
|
||||
|
||||
- name: Install dependencies (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake libncursesw5-dev libcurl4-openssl-dev
|
||||
sudo apt-get install -y cmake libgumbo-dev libssl-dev pkg-config g++
|
||||
|
||||
- name: Configure CMake
|
||||
- name: Configure CMake (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
mkdir -p build
|
||||
cd build
|
||||
cmake ..
|
||||
cmake -B build -DCMAKE_PREFIX_PATH=/opt/homebrew -DTUT_BUILD_TESTS=OFF
|
||||
|
||||
- name: Configure CMake (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
cmake -B build -DTUT_BUILD_TESTS=OFF
|
||||
|
||||
- name: Build project
|
||||
run: |
|
||||
cd build
|
||||
cmake --build .
|
||||
cmake --build build -j$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
|
||||
|
||||
- name: Test binary
|
||||
run: |
|
||||
./build/tut --version
|
||||
|
||||
- name: Rename binary with platform suffix
|
||||
run: |
|
||||
|
|
@ -84,10 +91,28 @@ jobs:
|
|||
body: |
|
||||
Automated release for commit ${{ github.sha }}
|
||||
|
||||
🎉 **TUT - Terminal UI Textual Browser**
|
||||
|
||||
A lightweight terminal browser with btop-style interface built with FTXUI.
|
||||
|
||||
## Features
|
||||
- 🎨 btop-style four-panel layout
|
||||
- ⚡ Fast and lightweight (~827KB binary)
|
||||
- ⌨️ Vim-style keyboard navigation
|
||||
- 🎭 Multiple color themes
|
||||
- 📝 TOML-based configuration
|
||||
|
||||
## Download
|
||||
- **macOS**: `tut-macos`
|
||||
- **Linux**: `tut-linux`
|
||||
|
||||
## Quick Start
|
||||
```bash
|
||||
chmod +x tut-macos # or tut-linux
|
||||
./tut-macos --help
|
||||
./tut-macos https://example.com
|
||||
```
|
||||
|
||||
## Build from source
|
||||
See the [README](https://github.com/${{ github.repository }}/blob/main/README.md) for build instructions.
|
||||
files: |
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
|||
# Build artifacts
|
||||
build/
|
||||
build_ftxui/
|
||||
build_*/
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
|
|
|||
379
CMakeLists.txt
379
CMakeLists.txt
|
|
@ -1,131 +1,316 @@
|
|||
cmake_minimum_required(VERSION 3.15)
|
||||
project(TUT_v2 VERSION 2.0.0 LANGUAGES CXX)
|
||||
# CMakeLists.txt
|
||||
# TUT - Terminal UI Textual Browser
|
||||
# https://github.com/m1ngsama/TUT
|
||||
|
||||
# C++17标准
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(TUT
|
||||
VERSION 0.1.0
|
||||
DESCRIPTION "Terminal UI Textual Browser with btop-style interface"
|
||||
HOMEPAGE_URL "https://github.com/m1ngsama/TUT"
|
||||
LANGUAGES CXX
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# C++ 标准配置
|
||||
# ============================================================================
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
# 导出编译命令 (用于 clangd 等工具)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
|
||||
# ============================================================================
|
||||
# 构建选项
|
||||
# ============================================================================
|
||||
option(TUT_STATIC_BUILD "Build static binary" OFF)
|
||||
option(TUT_BUILD_TESTS "Build tests" ON)
|
||||
option(TUT_BUILD_BENCHMARKS "Build benchmarks" OFF)
|
||||
option(TUT_ENABLE_ASAN "Enable AddressSanitizer" OFF)
|
||||
option(TUT_ENABLE_UBSAN "Enable UndefinedBehaviorSanitizer" OFF)
|
||||
|
||||
# 构建类型
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
|
||||
|
||||
# ============================================================================
|
||||
# 编译选项
|
||||
# ============================================================================
|
||||
# 基础警告选项
|
||||
add_compile_options(-Wall -Wextra -Wpedantic)
|
||||
|
||||
# macOS: Use Homebrew ncurses if available
|
||||
if(APPLE)
|
||||
set(CMAKE_PREFIX_PATH "/opt/homebrew/opt/ncurses" ${CMAKE_PREFIX_PATH})
|
||||
# Debug 和 Release 特定选项
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -DDEBUG")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG")
|
||||
set(CMAKE_CXX_FLAGS_RELWITHDEBINFO "-O2 -g -DNDEBUG")
|
||||
set(CMAKE_CXX_FLAGS_MINSIZEREL "-Os -DNDEBUG")
|
||||
|
||||
# Sanitizers
|
||||
if(TUT_ENABLE_ASAN)
|
||||
add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
|
||||
add_link_options(-fsanitize=address)
|
||||
endif()
|
||||
|
||||
# 查找依赖库
|
||||
find_package(CURL REQUIRED)
|
||||
find_package(Curses REQUIRED)
|
||||
if(TUT_ENABLE_UBSAN)
|
||||
add_compile_options(-fsanitize=undefined)
|
||||
add_link_options(-fsanitize=undefined)
|
||||
endif()
|
||||
|
||||
# 静态链接选项
|
||||
if(TUT_STATIC_BUILD)
|
||||
if(NOT APPLE)
|
||||
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static-libgcc -static-libstdc++")
|
||||
endif()
|
||||
set(BUILD_SHARED_LIBS OFF)
|
||||
endif()
|
||||
|
||||
# ============================================================================
|
||||
# 依赖管理 (优先使用系统包,回退到 FetchContent)
|
||||
# ============================================================================
|
||||
include(FetchContent)
|
||||
set(FETCHCONTENT_QUIET OFF)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# FTXUI - TUI 框架
|
||||
# macOS: brew install ftxui
|
||||
# ------------------------------------------------------------------------------
|
||||
find_package(ftxui CONFIG QUIET)
|
||||
if(NOT ftxui_FOUND)
|
||||
message(STATUS "FTXUI not found, using FetchContent...")
|
||||
FetchContent_Declare(
|
||||
ftxui
|
||||
GIT_REPOSITORY https://github.com/ArthurSonzogni/ftxui
|
||||
GIT_TAG v5.0.0
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
set(FTXUI_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(FTXUI_BUILD_DOCS OFF CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(ftxui)
|
||||
else()
|
||||
message(STATUS "Found FTXUI: ${ftxui_DIR}")
|
||||
endif()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# cpp-httplib - HTTP 客户端 (Header-only)
|
||||
# macOS: brew install cpp-httplib
|
||||
# ------------------------------------------------------------------------------
|
||||
find_package(httplib CONFIG QUIET)
|
||||
if(NOT httplib_FOUND)
|
||||
message(STATUS "cpp-httplib not found, using FetchContent...")
|
||||
FetchContent_Declare(
|
||||
httplib
|
||||
GIT_REPOSITORY https://github.com/yhirose/cpp-httplib
|
||||
GIT_TAG v0.14.3
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(httplib)
|
||||
else()
|
||||
message(STATUS "Found cpp-httplib: ${httplib_DIR}")
|
||||
endif()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# toml11 - TOML 配置解析 (Header-only)
|
||||
# macOS: brew install toml11
|
||||
# ------------------------------------------------------------------------------
|
||||
find_package(toml11 CONFIG QUIET)
|
||||
if(NOT toml11_FOUND)
|
||||
message(STATUS "toml11 not found, using FetchContent...")
|
||||
FetchContent_Declare(
|
||||
toml11
|
||||
GIT_REPOSITORY https://github.com/ToruNiina/toml11
|
||||
GIT_TAG v3.8.1
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(toml11)
|
||||
else()
|
||||
message(STATUS "Found toml11: ${toml11_DIR}")
|
||||
endif()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# gumbo-parser - HTML 解析器 (需要系统安装)
|
||||
# macOS: brew install gumbo-parser
|
||||
# Linux: apt install libgumbo-dev
|
||||
# ------------------------------------------------------------------------------
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GUMBO REQUIRED gumbo)
|
||||
|
||||
# 包含目录
|
||||
include_directories(
|
||||
# ------------------------------------------------------------------------------
|
||||
# OpenSSL - HTTPS 支持 (用于 cpp-httplib)
|
||||
# ------------------------------------------------------------------------------
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 线程库
|
||||
# ------------------------------------------------------------------------------
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
# ============================================================================
|
||||
# 版本信息生成
|
||||
# ============================================================================
|
||||
configure_file(
|
||||
"${CMAKE_SOURCE_DIR}/cmake/version.hpp.in"
|
||||
"${CMAKE_BINARY_DIR}/include/tut/version.hpp"
|
||||
@ONLY
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# 源文件列表
|
||||
# ============================================================================
|
||||
set(TUT_CORE_SOURCES
|
||||
src/core/browser_engine.cpp
|
||||
src/core/url_parser.cpp
|
||||
src/core/http_client.cpp
|
||||
src/core/bookmark_manager.cpp
|
||||
src/core/history_manager.cpp
|
||||
)
|
||||
|
||||
set(TUT_UI_SOURCES
|
||||
src/ui/main_window.cpp
|
||||
src/ui/address_bar.cpp
|
||||
src/ui/content_view.cpp
|
||||
src/ui/bookmark_panel.cpp
|
||||
src/ui/status_bar.cpp
|
||||
)
|
||||
|
||||
set(TUT_RENDERER_SOURCES
|
||||
src/renderer/html_renderer.cpp
|
||||
src/renderer/text_formatter.cpp
|
||||
src/renderer/style_parser.cpp
|
||||
)
|
||||
|
||||
set(TUT_UTILS_SOURCES
|
||||
src/utils/logger.cpp
|
||||
src/utils/config.cpp
|
||||
src/utils/theme.cpp
|
||||
)
|
||||
|
||||
set(TUT_ALL_SOURCES
|
||||
${TUT_CORE_SOURCES}
|
||||
${TUT_UI_SOURCES}
|
||||
${TUT_RENDERER_SOURCES}
|
||||
${TUT_UTILS_SOURCES}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# TUT 核心库 (用于测试复用)
|
||||
# ============================================================================
|
||||
add_library(tut_lib STATIC ${TUT_ALL_SOURCES})
|
||||
|
||||
target_include_directories(tut_lib PUBLIC
|
||||
${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}
|
||||
${CMAKE_SOURCE_DIR}/include
|
||||
${CMAKE_BINARY_DIR}/include
|
||||
${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
|
||||
target_link_directories(tut_lib PUBLIC
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(test_layout
|
||||
${CURSES_LIBRARIES}
|
||||
target_link_libraries(tut_lib PUBLIC
|
||||
ftxui::screen
|
||||
ftxui::dom
|
||||
ftxui::component
|
||||
httplib::httplib
|
||||
toml11::toml11
|
||||
${GUMBO_LIBRARIES}
|
||||
OpenSSL::SSL
|
||||
OpenSSL::Crypto
|
||||
Threads::Threads
|
||||
)
|
||||
|
||||
# ==================== TUT 2.0 主程序 ====================
|
||||
# ============================================================================
|
||||
# TUT 可执行文件
|
||||
# ============================================================================
|
||||
add_executable(tut src/main.cpp)
|
||||
|
||||
add_executable(tut2
|
||||
src/main_v2.cpp
|
||||
src/browser_v2.cpp
|
||||
src/http_client.cpp
|
||||
src/input_handler.cpp
|
||||
src/bookmark.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_libraries(tut PRIVATE tut_lib)
|
||||
|
||||
# ============================================================================
|
||||
# 安装规则
|
||||
# ============================================================================
|
||||
include(GNUInstallDirs)
|
||||
|
||||
install(TARGETS tut
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||
)
|
||||
|
||||
target_link_directories(tut2 PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
install(DIRECTORY assets/themes/
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/tut/themes
|
||||
OPTIONAL
|
||||
)
|
||||
|
||||
target_link_libraries(tut2
|
||||
${CURSES_LIBRARIES}
|
||||
CURL::libcurl
|
||||
${GUMBO_LIBRARIES}
|
||||
install(DIRECTORY assets/keybindings/
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/tut/keybindings
|
||||
OPTIONAL
|
||||
)
|
||||
|
||||
# ==================== 旧版主程序 (向后兼容) ====================
|
||||
# ============================================================================
|
||||
# 测试
|
||||
# ============================================================================
|
||||
if(TUT_BUILD_TESTS)
|
||||
enable_testing()
|
||||
|
||||
add_executable(tut
|
||||
src/main.cpp
|
||||
src/browser.cpp
|
||||
src/http_client.cpp
|
||||
src/text_renderer.cpp
|
||||
src/input_handler.cpp
|
||||
src/dom_tree.cpp
|
||||
src/html_parser.cpp
|
||||
# Google Test
|
||||
FetchContent_Declare(
|
||||
googletest
|
||||
GIT_REPOSITORY https://github.com/google/googletest
|
||||
GIT_TAG v1.14.0
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
|
||||
target_link_directories(tut PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
# 添加测试子目录
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
target_link_libraries(tut
|
||||
${CURSES_LIBRARIES}
|
||||
CURL::libcurl
|
||||
${GUMBO_LIBRARIES}
|
||||
)
|
||||
# ============================================================================
|
||||
# 打包配置 (CPack)
|
||||
# ============================================================================
|
||||
set(CPACK_PACKAGE_NAME "tut")
|
||||
set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})
|
||||
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY ${PROJECT_DESCRIPTION})
|
||||
set(CPACK_PACKAGE_VENDOR "m1ngsama")
|
||||
set(CPACK_PACKAGE_CONTACT "m1ngsama")
|
||||
set(CPACK_PACKAGE_HOMEPAGE_URL ${PROJECT_HOMEPAGE_URL})
|
||||
set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE")
|
||||
set(CPACK_RESOURCE_FILE_README "${CMAKE_SOURCE_DIR}/README.md")
|
||||
|
||||
# 打包格式
|
||||
set(CPACK_GENERATOR "TGZ;ZIP")
|
||||
if(APPLE)
|
||||
list(APPEND CPACK_GENERATOR "DragNDrop")
|
||||
elseif(UNIX)
|
||||
list(APPEND CPACK_GENERATOR "DEB;RPM")
|
||||
endif()
|
||||
|
||||
# Debian 包配置
|
||||
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libgumbo1, libssl1.1 | libssl3")
|
||||
set(CPACK_DEBIAN_PACKAGE_SECTION "web")
|
||||
|
||||
# RPM 包配置
|
||||
set(CPACK_RPM_PACKAGE_REQUIRES "gumbo-parser, openssl-libs")
|
||||
set(CPACK_RPM_PACKAGE_GROUP "Applications/Internet")
|
||||
|
||||
include(CPack)
|
||||
|
||||
# ============================================================================
|
||||
# 构建信息摘要
|
||||
# ============================================================================
|
||||
message(STATUS "")
|
||||
message(STATUS "========== TUT Build Configuration ==========")
|
||||
message(STATUS "Version: ${PROJECT_VERSION}")
|
||||
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
|
||||
message(STATUS "C++ Standard: ${CMAKE_CXX_STANDARD}")
|
||||
message(STATUS "C++ Compiler: ${CMAKE_CXX_COMPILER_ID} ${CMAKE_CXX_COMPILER_VERSION}")
|
||||
message(STATUS "Static build: ${TUT_STATIC_BUILD}")
|
||||
message(STATUS "Build tests: ${TUT_BUILD_TESTS}")
|
||||
message(STATUS "Build benchmarks: ${TUT_BUILD_BENCHMARKS}")
|
||||
message(STATUS "ASAN enabled: ${TUT_ENABLE_ASAN}")
|
||||
message(STATUS "UBSAN enabled: ${TUT_ENABLE_UBSAN}")
|
||||
message(STATUS "Install prefix: ${CMAKE_INSTALL_PREFIX}")
|
||||
message(STATUS "==============================================")
|
||||
message(STATUS "")
|
||||
|
|
|
|||
136
KEYBOARD.md
Normal file
136
KEYBOARD.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# TUT Browser - Keyboard Shortcuts
|
||||
|
||||
## 🎯 Quick Reference
|
||||
|
||||
### Navigation
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `o` | Open address bar (type URL and press Enter) |
|
||||
| `Backspace` | Go back |
|
||||
| `f` | Go forward |
|
||||
| `r` or `F5` | Refresh current page |
|
||||
| `q` or `Esc` or `F10` | Quit browser |
|
||||
|
||||
### Scrolling
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `j` or `↓` | Scroll down one line |
|
||||
| `k` or `↑` | Scroll up one line |
|
||||
| `Space` or `PageDown` | Page down |
|
||||
| `b` or `PageUp` | Page up |
|
||||
| `g` | Go to top |
|
||||
| `G` | Go to bottom |
|
||||
|
||||
### Links
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Tab` | Select next link |
|
||||
| `Shift+Tab` | Select previous link |
|
||||
| `1-9` | Jump to link by number |
|
||||
| `Enter` | Follow selected link |
|
||||
|
||||
## 📝 Usage Examples
|
||||
|
||||
### Basic Browsing
|
||||
```bash
|
||||
./build_ftxui/tut https://example.com
|
||||
|
||||
# 1. Press 'j' or 'k' to scroll
|
||||
# 2. Press 'Tab' to cycle through links
|
||||
# 3. Press 'Enter' to follow the selected link
|
||||
# 4. Press 'Backspace' to go back
|
||||
# 5. Press 'q' to quit
|
||||
```
|
||||
|
||||
### Direct Link Navigation
|
||||
```bash
|
||||
./build_ftxui/tut https://tldp.org/HOWTO/HOWTO-INDEX/howtos.html
|
||||
|
||||
# See numbered links like [1], [2], [3]...
|
||||
# Press '1' to jump to first link
|
||||
# Press '2' to jump to second link
|
||||
# Press 'Enter' to follow the selected link
|
||||
```
|
||||
|
||||
### Address Bar
|
||||
```bash
|
||||
# 1. Press 'o' to open address bar
|
||||
# 2. Type new URL
|
||||
# 3. Press 'Enter' to navigate
|
||||
# 4. Press 'Esc' to cancel
|
||||
```
|
||||
|
||||
### In-Page Search
|
||||
```bash
|
||||
# Search for text on current page
|
||||
# 1. Press '/' to open search bar
|
||||
# 2. Type your search query (case-insensitive)
|
||||
# 3. Press 'Enter' to find all matches
|
||||
# 4. Press 'n' to go to next match
|
||||
# 5. Press 'N' to go to previous match
|
||||
# 6. Matches are highlighted (yellow = current, blue = other matches)
|
||||
# 7. Status bar shows "Match X/Y" count
|
||||
```
|
||||
|
||||
## 🎨 UI Elements
|
||||
|
||||
### Top Bar
|
||||
- `[◀]` - Back button (dimmed when can't go back)
|
||||
- `[▶]` - Forward button (dimmed when can't go forward)
|
||||
- `[⟳]` - Refresh
|
||||
- Address bar - Shows current URL
|
||||
- `[⚙]` - Settings (not yet implemented)
|
||||
- `[?]` - Help (not yet implemented)
|
||||
|
||||
### Content Area
|
||||
- Shows rendered HTML content
|
||||
- Displays page title at top
|
||||
- Shows scroll position at bottom
|
||||
|
||||
### Bottom Panels
|
||||
- **Bookmarks Panel** - Shows bookmarks (not yet implemented)
|
||||
- **Status Panel** - Shows:
|
||||
- Load stats (KB downloaded, time, link count)
|
||||
- Currently selected link URL
|
||||
|
||||
### Status Bar
|
||||
- Shows function key shortcuts
|
||||
- Shows current status message
|
||||
|
||||
## ⚡ Pro Tips
|
||||
|
||||
1. **Fast Navigation**: Use number keys (1-9) to instantly jump to links
|
||||
2. **Quick Scrolling**: Use `Space` and `b` for fast page scrolling
|
||||
3. **Link Preview**: Watch the status bar to see link URLs before following
|
||||
4. **Efficient Browsing**: Use `g` to jump to top, `G` to jump to bottom
|
||||
5. **Address Bar**: Type `o` quickly to enter a new URL
|
||||
|
||||
### Search
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `/` | Start search (type query and press Enter) |
|
||||
| `n` | Next search result |
|
||||
| `N` | Previous search result |
|
||||
| `Esc` | Cancel search |
|
||||
|
||||
### Bookmarks
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Ctrl+D` | Add/remove current page as bookmark |
|
||||
| `F2` | Toggle bookmark panel |
|
||||
|
||||
### History
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `F3` | Toggle history panel |
|
||||
|
||||
## 🐛 Known Limitations
|
||||
|
||||
- Ctrl+L not yet working for address bar (use 'o' instead)
|
||||
- Cannot navigate to bookmarks/history from panel yet (coming soon)
|
||||
|
||||
## 🚀 Coming Soon
|
||||
|
||||
- [ ] Navigate to bookmarks/history from panel (click/select)
|
||||
- [ ] Better link highlighting
|
||||
- [ ] Form support
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 m1ngsama
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
# Quick Link Navigation Guide
|
||||
|
||||
The browser now supports vim-style quick navigation to links!
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Visual Link Numbers
|
||||
All links are displayed inline in the text with numbers like `[0]`, `[1]`, `[2]`, etc.
|
||||
- Links are shown with yellow color and underline
|
||||
- Active link (selected with Tab) has yellow background
|
||||
|
||||
### 2. Quick Navigation Methods
|
||||
|
||||
#### Method 1: Number + Enter
|
||||
Type a number and press Enter to jump to that link:
|
||||
```
|
||||
3<Enter> - Jump to link [3]
|
||||
10<Enter> - Jump to link [10]
|
||||
```
|
||||
|
||||
#### Method 2: 'f' command (follow)
|
||||
Press `f` followed by a number to immediately open that link:
|
||||
```
|
||||
f3 - Open link [3] directly
|
||||
f10 - Open link [10] directly
|
||||
```
|
||||
|
||||
Or type the number first:
|
||||
```
|
||||
3f - Open link [3] directly
|
||||
10f - Open link [10] directly
|
||||
```
|
||||
|
||||
#### Method 3: Traditional Tab navigation (still works)
|
||||
```
|
||||
Tab - Next link
|
||||
Shift-Tab/T - Previous link
|
||||
Enter - Follow current highlighted link
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Given a page with these links:
|
||||
- "Google[0]"
|
||||
- "GitHub[1]"
|
||||
- "Wikipedia[2]"
|
||||
|
||||
You can:
|
||||
- Press `1<Enter>` to select GitHub link
|
||||
- Press `f2` to immediately open Wikipedia
|
||||
- Press `Tab` twice then `Enter` to open Wikipedia
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Test with a real website
|
||||
./tut https://example.com
|
||||
|
||||
# View help
|
||||
./tut
|
||||
# Press ? for help
|
||||
```
|
||||
|
||||
## Key Bindings Summary
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `[N]<Enter>` | Jump to link N |
|
||||
| `f[N]` or `[N]f` | Open link N directly |
|
||||
| `Tab` | Next link |
|
||||
| `Shift-Tab` / `T` | Previous link |
|
||||
| `Enter` | Follow current link |
|
||||
| `h` | Go back |
|
||||
| `l` | Go forward |
|
||||
|
||||
All standard vim navigation keys (j/k, gg/G, /, n/N) still work as before!
|
||||
28
Makefile
28
Makefile
|
|
@ -1,28 +0,0 @@
|
|||
# Simple Makefile wrapper for CMake build system
|
||||
# Follows Unix convention: simple interface to underlying build
|
||||
|
||||
BUILD_DIR = build
|
||||
TARGET = tut
|
||||
|
||||
.PHONY: all clean install test help
|
||||
|
||||
all:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
@cd $(BUILD_DIR) && cmake .. && cmake --build .
|
||||
@cp $(BUILD_DIR)/$(TARGET) .
|
||||
|
||||
clean:
|
||||
@rm -rf $(BUILD_DIR) $(TARGET)
|
||||
|
||||
install: all
|
||||
@install -m 755 $(TARGET) /usr/local/bin/
|
||||
|
||||
test: all
|
||||
@./$(TARGET) https://example.com
|
||||
|
||||
help:
|
||||
@echo "TUT Browser - Simple make targets"
|
||||
@echo " make - Build the browser"
|
||||
@echo " make clean - Remove build artifacts"
|
||||
@echo " make install - Install to /usr/local/bin"
|
||||
@echo " make test - Quick test run"
|
||||
144
NEXT_STEPS.md
144
NEXT_STEPS.md
|
|
@ -1,144 +0,0 @@
|
|||
# TUT 2.0 - 下次继续从这里开始
|
||||
|
||||
## 当前位置
|
||||
- **阶段**: Phase 5 - 书签管理 (已完成!)
|
||||
- **进度**: 书签添加/删除/持久化存储已完成
|
||||
- **最后提交**: `feat: Add bookmark management`
|
||||
|
||||
## 立即可做的事
|
||||
|
||||
### 1. 启用图片支持 (首次使用时需要)
|
||||
```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
|
||||
```
|
||||
|
||||
### 2. 使用书签功能
|
||||
- **B** - 添加当前页面到书签
|
||||
- **D** - 从书签中移除当前页面
|
||||
- **:bookmarks** 或 **:bm** - 查看书签列表
|
||||
|
||||
书签存储在 `~/.config/tut/bookmarks.json`
|
||||
|
||||
## 已完成的功能清单
|
||||
|
||||
### Phase 5 - 书签管理
|
||||
- [x] 书签数据结构 (URL, 标题, 添加时间)
|
||||
- [x] JSON 持久化存储 (~/.config/tut/bookmarks.json)
|
||||
- [x] 添加书签 (B 键)
|
||||
- [x] 删除书签 (D 键)
|
||||
- [x] 书签列表页面 (:bookmarks 命令)
|
||||
- [x] 书签链接可点击跳转
|
||||
|
||||
### Phase 4 - 图片支持
|
||||
- [x] `<img>` 标签解析 (src, alt, width, height)
|
||||
- [x] 图片占位符显示 `[alt text]` 或 `[Image: filename]`
|
||||
- [x] `BinaryResponse` 结构体
|
||||
- [x] `HttpClient::fetch_binary()` 方法
|
||||
- [x] `ImageRenderer` 类框架
|
||||
- [x] PPM 格式内置解码
|
||||
- [x] stb_image.h 集成 (PNG/JPEG/GIF/BMP 支持)
|
||||
- [x] 浏览器中的图片下载和渲染
|
||||
- [x] ASCII Art 彩色渲染 (True Color)
|
||||
|
||||
### Phase 3 - 性能优化
|
||||
- [x] LRU 页面缓存 (20页, 5分钟过期)
|
||||
- [x] 差分渲染 (只更新变化的单元格)
|
||||
- [x] 批量输出优化
|
||||
- [x] 加载状态指示
|
||||
|
||||
### Phase 2 - 交互增强
|
||||
- [x] 搜索功能 (/, n/N)
|
||||
- [x] 搜索高亮
|
||||
- [x] Tab 切换链接时自动滚动
|
||||
- [x] 窗口大小动态调整
|
||||
- [x] 表单渲染 (input, button, checkbox, radio, select)
|
||||
- [x] POST 表单提交
|
||||
|
||||
### Phase 1 - 核心架构
|
||||
- [x] Terminal 抽象层 (raw mode, True Color)
|
||||
- [x] FrameBuffer 双缓冲
|
||||
- [x] Renderer 差分渲染
|
||||
- [x] LayoutEngine 布局引擎
|
||||
- [x] DocumentRenderer 文档渲染
|
||||
- [x] Unicode 宽度计算 (CJK 支持)
|
||||
- [x] 温暖护眼配色方案
|
||||
|
||||
## 代码结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── browser_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. **异步 HTTP 请求** - 非阻塞加载,加载动画,可取消请求
|
||||
2. **更多表单交互** - 文本输入编辑,下拉选择
|
||||
3. **图片缓存** - 避免重复下载相同图片
|
||||
4. **历史记录管理** - 持久化历史记录,历史页面
|
||||
|
||||
## 恢复对话时说
|
||||
|
||||
> "继续TUT 2.0开发"
|
||||
|
||||
---
|
||||
更新时间: 2025-12-27
|
||||
412
README.md
412
README.md
|
|
@ -1,272 +1,202 @@
|
|||
TUT(1) - Terminal User Interface Browser
|
||||
========================================
|
||||
# TUT - Terminal UI Textual Browser
|
||||
|
||||
NAME
|
||||
----
|
||||
tut - vim-style terminal web browser
|
||||
A lightweight, high-performance terminal browser with a btop-style interface.
|
||||
|
||||
SYNOPSIS
|
||||
--------
|
||||
**tut** [*URL*]
|
||||

|
||||

|
||||

|
||||
|
||||
**tut** **-h** | **--help**
|
||||
## Features
|
||||
|
||||
DESCRIPTION
|
||||
-----------
|
||||
**tut** is a text-mode web browser designed for comfortable reading in the
|
||||
terminal. It extracts and displays the textual content of web pages with a
|
||||
clean, centered layout optimized for reading, while providing vim-style
|
||||
keyboard navigation.
|
||||
- **btop-style UI** - Modern four-panel layout with rounded borders
|
||||
- **Lightweight** - Binary size < 1MB, memory usage < 50MB
|
||||
- **Fast startup** - Launch in < 500ms
|
||||
- **Vim-style navigation** - j/k scrolling, / search, g/G jump
|
||||
- **Keyboard-driven** - Full keyboard navigation with function key shortcuts
|
||||
- **Themeable** - Multiple color themes (default, nord, gruvbox, solarized)
|
||||
- **Configurable** - TOML-based configuration
|
||||
|
||||
The browser does not execute JavaScript or display images. It is designed
|
||||
for reading static HTML content, documentation, and text-heavy websites.
|
||||
## Screenshot
|
||||
|
||||
OPTIONS
|
||||
-------
|
||||
*URL*
|
||||
Open the specified URL on startup. If omitted, displays the built-in
|
||||
help page.
|
||||
```
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│[◀] [▶] [⟳] ╭────────────────────────────────────────────────────────╮ [⚙] [?]│
|
||||
│ │https://example.com │ │
|
||||
│ ╰────────────────────────────────────────────────────────╯ │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Example Domain │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│This domain is for use in illustrative examples in documents. │
|
||||
│ │
|
||||
│[1] More information... │
|
||||
│ │
|
||||
├────────────────────────────────────────┬─────────────────────────────────────┤
|
||||
│📑 Bookmarks │📊 Status │
|
||||
│ example.com │ ⬇ 1.2 KB 🕐 0.3s │
|
||||
├────────────────────────────────────────┴─────────────────────────────────────┤
|
||||
│[F1]Help [F2]Bookmarks [F3]History [F10]Quit │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
**-h**, **--help**
|
||||
Display usage information and exit.
|
||||
## Installation
|
||||
|
||||
KEYBINDINGS
|
||||
-----------
|
||||
**tut** uses vim-style keybindings throughout.
|
||||
|
||||
### Navigation
|
||||
|
||||
**j**, **Down**
|
||||
Scroll down one line.
|
||||
|
||||
**k**, **Up**
|
||||
Scroll up one line.
|
||||
|
||||
**Ctrl-D**, **Space**
|
||||
Scroll down one page.
|
||||
|
||||
**Ctrl-U**, **b**
|
||||
Scroll up one page.
|
||||
|
||||
**gg**
|
||||
Jump to top of page.
|
||||
|
||||
**G**
|
||||
Jump to bottom of page.
|
||||
|
||||
**[***count***]G**
|
||||
Jump to line *count* (e.g., **50G** jumps to line 50).
|
||||
|
||||
**[***count***]j**, **[***count***]k**
|
||||
Scroll down/up *count* lines (e.g., **5j** scrolls down 5 lines).
|
||||
|
||||
### Link Navigation
|
||||
|
||||
**Tab**
|
||||
Move to next link.
|
||||
|
||||
**Shift-Tab**, **T**
|
||||
Move to previous link.
|
||||
|
||||
**Enter**
|
||||
Follow current link.
|
||||
|
||||
**h**, **Left**
|
||||
Go back in history.
|
||||
|
||||
**l**, **Right**
|
||||
Go forward in history.
|
||||
|
||||
### Search
|
||||
|
||||
**/**
|
||||
Start search. Enter search term and press **Enter**.
|
||||
|
||||
**n**
|
||||
Jump to next search match.
|
||||
|
||||
**N**
|
||||
Jump to previous search match.
|
||||
|
||||
### Marks
|
||||
|
||||
**m***[a-z]*
|
||||
Set mark at current position (e.g., **ma**, **mb**).
|
||||
|
||||
**'***[a-z]*
|
||||
Jump to mark (e.g., **'a**, **'b**).
|
||||
|
||||
### Mouse
|
||||
|
||||
**Left Click**
|
||||
Click on links to follow them directly.
|
||||
|
||||
**Scroll Wheel Up/Down**
|
||||
Scroll page up or down.
|
||||
|
||||
Works with most modern terminal emulators that support mouse events.
|
||||
|
||||
### Commands
|
||||
|
||||
Press **:** to enter command mode. Available commands:
|
||||
|
||||
**:q**, **:quit**
|
||||
Quit the browser.
|
||||
|
||||
**:o** *URL*, **:open** *URL*
|
||||
Open *URL*.
|
||||
|
||||
**:r**, **:refresh**
|
||||
Reload current page.
|
||||
|
||||
**:h**, **:help**
|
||||
Display help page.
|
||||
|
||||
**:***number*
|
||||
Jump to line *number*.
|
||||
|
||||
### Other
|
||||
|
||||
**r**
|
||||
Reload current page.
|
||||
|
||||
**q**
|
||||
Quit the browser.
|
||||
|
||||
**?**
|
||||
Display help page.
|
||||
|
||||
**ESC**
|
||||
Cancel command or search input.
|
||||
|
||||
LIMITATIONS
|
||||
-----------
|
||||
**tut** does not execute JavaScript. Modern single-page applications (SPAs)
|
||||
built with React, Vue, Angular, or similar frameworks will not work correctly,
|
||||
as they require JavaScript to render content.
|
||||
|
||||
To determine if a site will work with **tut**, use:
|
||||
|
||||
curl https://example.com | less
|
||||
|
||||
If you can see the actual content in the HTML source, the site will work.
|
||||
If you only see JavaScript code or empty div elements, it will not.
|
||||
|
||||
Additionally:
|
||||
- No image display
|
||||
- No CSS layout support
|
||||
- No AJAX or dynamic content loading
|
||||
|
||||
EXAMPLES
|
||||
--------
|
||||
View the built-in help:
|
||||
|
||||
tut
|
||||
|
||||
Browse Hacker News:
|
||||
|
||||
tut https://news.ycombinator.com
|
||||
|
||||
Read Wikipedia:
|
||||
|
||||
tut https://en.wikipedia.org/wiki/Unix_philosophy
|
||||
|
||||
Open a URL, search for "unix", and navigate:
|
||||
|
||||
tut https://example.com
|
||||
/unix<Enter>
|
||||
n
|
||||
|
||||
DEPENDENCIES
|
||||
------------
|
||||
- ncurses or ncursesw (for terminal UI)
|
||||
- libcurl (for HTTPS support)
|
||||
- CMake >= 3.15 (build time)
|
||||
- C++17 compiler (build time)
|
||||
|
||||
INSTALLATION
|
||||
------------
|
||||
### From Source
|
||||
### Prerequisites
|
||||
|
||||
**macOS (Homebrew):**
|
||||
|
||||
brew install cmake ncurses curl
|
||||
mkdir -p build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
sudo install -m 755 tut /usr/local/bin/
|
||||
```bash
|
||||
brew install cmake gumbo-parser openssl ftxui cpp-httplib toml11
|
||||
```
|
||||
|
||||
**Linux (Debian/Ubuntu):**
|
||||
```bash
|
||||
sudo apt install cmake libgumbo-dev libssl-dev
|
||||
```
|
||||
|
||||
sudo apt-get install cmake libncursesw5-dev libcurl4-openssl-dev
|
||||
mkdir -p build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
sudo install -m 755 tut /usr/local/bin/
|
||||
### Building from Source
|
||||
|
||||
**Linux (Fedora/RHEL):**
|
||||
```bash
|
||||
git clone https://github.com/m1ngsama/TUT.git
|
||||
cd TUT
|
||||
cmake -B build -DCMAKE_PREFIX_PATH=/opt/homebrew # macOS
|
||||
cmake -B build # Linux
|
||||
cmake --build build -j$(nproc)
|
||||
```
|
||||
|
||||
sudo dnf install cmake gcc-c++ ncurses-devel libcurl-devel
|
||||
mkdir -p build && cd build
|
||||
cmake ..
|
||||
cmake --build .
|
||||
sudo install -m 755 tut /usr/local/bin/
|
||||
### Running
|
||||
|
||||
### Using Makefile
|
||||
```bash
|
||||
./build/tut # Start with blank page
|
||||
./build/tut https://example.com # Open URL directly
|
||||
./build/tut --help # Show help
|
||||
```
|
||||
|
||||
make
|
||||
sudo make install
|
||||
## Keyboard Shortcuts
|
||||
|
||||
FILES
|
||||
-----
|
||||
No configuration files are used. The browser is stateless and does not
|
||||
store history, cookies, or cache.
|
||||
### Navigation
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `j` / `↓` | Scroll down |
|
||||
| `k` / `↑` | Scroll up |
|
||||
| `Space` | Page down |
|
||||
| `b` | Page up |
|
||||
| `g` | Go to top |
|
||||
| `G` | Go to bottom |
|
||||
| `Backspace` | Go back |
|
||||
| `f` | Go forward |
|
||||
|
||||
ENVIRONMENT
|
||||
-----------
|
||||
**tut** respects the following environment variables:
|
||||
### Links
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Tab` | Next link |
|
||||
| `Shift+Tab` | Previous link |
|
||||
| `Enter` | Follow link |
|
||||
| `1-9` | Jump to link by number |
|
||||
|
||||
**TERM**
|
||||
Terminal type. Must support basic cursor movement and colors.
|
||||
### Search
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `/` | Start search |
|
||||
| `n` | Next result |
|
||||
| `N` | Previous result |
|
||||
|
||||
**LINES**, **COLUMNS**
|
||||
Terminal size. Automatically detected via ncurses.
|
||||
### UI
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Ctrl+L` | Focus address bar |
|
||||
| `F1` / `?` | Help |
|
||||
| `F2` | Bookmarks |
|
||||
| `F3` | History |
|
||||
| `Ctrl+D` | Add bookmark |
|
||||
| `Ctrl+Q` / `F10` / `q` | Quit |
|
||||
|
||||
EXIT STATUS
|
||||
-----------
|
||||
**0**
|
||||
Success.
|
||||
## Configuration
|
||||
|
||||
**1**
|
||||
Error occurred (e.g., invalid URL, network error, ncurses initialization
|
||||
failure).
|
||||
Configuration files are stored in `~/.config/tut/`:
|
||||
|
||||
PHILOSOPHY
|
||||
----------
|
||||
**tut** follows the Unix philosophy:
|
||||
```
|
||||
~/.config/tut/
|
||||
├── config.toml # Main configuration
|
||||
└── themes/ # Custom themes
|
||||
└── mytheme.toml
|
||||
```
|
||||
|
||||
1. Do one thing well: display and navigate text content from the web.
|
||||
2. Work with other programs: output can be piped, URLs can come from stdin.
|
||||
3. Simple and minimal: no configuration files, no persistent state.
|
||||
4. Text-focused: everything is text, processed and displayed cleanly.
|
||||
### Example config.toml
|
||||
|
||||
The design emphasizes keyboard efficiency, clean output, and staying out
|
||||
of your way.
|
||||
```toml
|
||||
[general]
|
||||
theme = "default"
|
||||
homepage = "https://example.com"
|
||||
debug = false
|
||||
|
||||
SEE ALSO
|
||||
--------
|
||||
lynx(1), w3m(1), curl(1), vim(1)
|
||||
[browser]
|
||||
timeout = 30
|
||||
user_agent = "TUT/0.1.0"
|
||||
|
||||
BUGS
|
||||
----
|
||||
Report bugs at: https://github.com/m1ngsama/TUT/issues
|
||||
[ui]
|
||||
word_wrap = true
|
||||
show_images = true
|
||||
```
|
||||
|
||||
AUTHORS
|
||||
-------
|
||||
m1ngsama <contact@m1ng.space>
|
||||
## Project Structure
|
||||
|
||||
Inspired by lynx, w3m, and vim.
|
||||
```
|
||||
TUT/
|
||||
├── CMakeLists.txt # Build configuration
|
||||
├── README.md # This file
|
||||
├── LICENSE # MIT License
|
||||
├── cmake/ # CMake modules
|
||||
│ └── version.hpp.in
|
||||
├── src/ # Source code
|
||||
│ ├── main.cpp # Entry point
|
||||
│ ├── core/ # Browser engine, HTTP, URL parsing
|
||||
│ ├── ui/ # FTXUI components
|
||||
│ ├── renderer/ # HTML rendering
|
||||
│ └── utils/ # Logger, config, themes
|
||||
├── tests/ # Unit and integration tests
|
||||
│ ├── unit/
|
||||
│ └── integration/
|
||||
└── assets/ # Default configurations
|
||||
├── config.toml
|
||||
├── themes/
|
||||
└── keybindings/
|
||||
```
|
||||
|
||||
LICENSE
|
||||
-------
|
||||
MIT License. See LICENSE file for details.
|
||||
## Dependencies
|
||||
|
||||
| Library | Purpose | Version |
|
||||
|---------|---------|---------|
|
||||
| [FTXUI](https://github.com/ArthurSonzogni/ftxui) | Terminal UI framework | 5.0+ |
|
||||
| [cpp-httplib](https://github.com/yhirose/cpp-httplib) | HTTP client | 0.14+ |
|
||||
| [gumbo-parser](https://github.com/google/gumbo-parser) | HTML parsing | 0.10+ |
|
||||
| [toml11](https://github.com/ToruNiina/toml11) | TOML configuration | 3.8+ |
|
||||
| [OpenSSL](https://www.openssl.org/) | HTTPS support | 1.1+ |
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No JavaScript** - SPAs and dynamic content won't work
|
||||
- **No CSS layout** - Only basic text formatting
|
||||
- **No images** - ASCII art rendering planned for future
|
||||
- **Text-only** - Focused on readable content
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read the coding style guidelines:
|
||||
|
||||
- C++17 standard
|
||||
- Google C++ Style Guide
|
||||
- Use `.hpp` for headers, `.cpp` for implementation
|
||||
- All public APIs must have documentation comments
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Authors
|
||||
|
||||
- **m1ngsama** - [GitHub](https://github.com/m1ngsama)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Inspired by [btop](https://github.com/aristocratos/btop) for UI design
|
||||
- [FTXUI](https://github.com/ArthurSonzogni/ftxui) for the amazing TUI framework
|
||||
- [lynx](https://lynx.invisible-island.net/) and [w3m](http://w3m.sourceforge.net/) for inspiration
|
||||
|
|
|
|||
142
STATUS.md
Normal file
142
STATUS.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# TUT Browser - Development Status
|
||||
|
||||
## ✅ Working Features (v0.2.0-alpha) - INTERACTIVE!
|
||||
|
||||
### Core Functionality
|
||||
- ✅ **HTTP/HTTPS Client** - Fully functional with cpp-httplib
|
||||
- GET/POST/HEAD requests
|
||||
- SSL/TLS support
|
||||
- Cookie management
|
||||
- Redirect following
|
||||
- Timeout handling
|
||||
|
||||
- ✅ **HTML Parsing** - Working with gumbo-parser
|
||||
- Full HTML5 parsing
|
||||
- DOM tree construction
|
||||
- Title extraction
|
||||
- Link extraction with numbering
|
||||
|
||||
- ✅ **Content Rendering** - Basic text-based rendering
|
||||
- Headings (H1-H6) with bold formatting
|
||||
- Lists (UL/OL) with bullet points
|
||||
- Links with [N] numbering and blue underline
|
||||
- Paragraph and block element handling
|
||||
- Skip script/style/head tags
|
||||
|
||||
- ✅ **Browser Engine** - Integrated pipeline
|
||||
- Fetches URLs via HTTP client
|
||||
- Parses HTML with renderer
|
||||
- Resolves relative URLs
|
||||
- Error handling for failed requests
|
||||
- Back/forward navigation history
|
||||
|
||||
- ✅ **Interactive UI** - Fully keyboard-driven navigation
|
||||
- **Content Scrolling** - j/k, g/G, Space/b for navigation
|
||||
- **Link Navigation** - Tab, number keys (1-9), Enter to follow
|
||||
- **Address Bar** - 'o' to open, type URL, Enter to navigate
|
||||
- **Browser Controls** - Backspace to go back, 'f' to go forward, r/F5 to refresh
|
||||
- **In-Page Search** - '/' to search, n/N to navigate results, highlighted matches
|
||||
- **Bookmark System** - Ctrl+D to add/remove, F2 to toggle panel, JSON persistence
|
||||
- **History System** - Auto-record visits, F3 to toggle panel, updates on revisit, JSON persistence
|
||||
- **Real-time Status** - Load stats, scroll position, selected link, search results
|
||||
- **Visual Feedback** - Navigation button states, link highlighting, search highlighting
|
||||
|
||||
### Build & Deployment
|
||||
- ✅ Binary size: **827KB** (well under 1MB target!)
|
||||
- ✅ Clean compilation with no warnings
|
||||
- ✅ All tests build successfully
|
||||
- ✅ CI/CD pipeline configured
|
||||
- ✅ macOS and Linux support
|
||||
|
||||
## ⚠️ Known Limitations
|
||||
|
||||
### Feature Gaps
|
||||
- ⚠️ No form support (input fields, buttons, etc.)
|
||||
- ⚠️ No image rendering (even ASCII art)
|
||||
- ⚠️ No CSS parsing (only basic tag-based formatting)
|
||||
- ⚠️ No JavaScript support (by design)
|
||||
|
||||
## 🎯 Next Steps Priority
|
||||
|
||||
### Phase 2: Advanced Features (Medium Priority)
|
||||
1. **Improve Rendering**
|
||||
- Better word wrapping
|
||||
- Table rendering
|
||||
- Code block formatting
|
||||
- Better list indentation
|
||||
|
||||
2. **Add Form Support**
|
||||
- Input field rendering
|
||||
- Button rendering
|
||||
- Form submission
|
||||
|
||||
3. **Add Image Support**
|
||||
- ASCII art rendering
|
||||
- Image-to-text conversion
|
||||
|
||||
## 📊 Test Results
|
||||
|
||||
```bash
|
||||
./test_browse.sh
|
||||
|
||||
Test 1: TLDP HOWTO index - ✅ PASSED
|
||||
Test 2: example.com - ✅ PASSED
|
||||
|
||||
Interactive test:
|
||||
./build_ftxui/tut https://tldp.org/HOWTO/HOWTO-INDEX/howtos.html
|
||||
|
||||
✅ Scrolling with j/k - WORKS
|
||||
✅ Tab to cycle links - WORKS
|
||||
✅ Press '1' to jump to link 1 - WORKS
|
||||
✅ Enter to follow link - WORKS
|
||||
✅ Backspace to go back - WORKS
|
||||
✅ 'f' to go forward - WORKS
|
||||
✅ '/' to search - WORKS
|
||||
✅ 'n'/'N' to navigate search results - WORKS
|
||||
✅ Ctrl+D to add/remove bookmark - WORKS
|
||||
✅ F2 to toggle bookmark panel - WORKS
|
||||
✅ F3 to toggle history panel - WORKS
|
||||
✅ Auto-record page visits in history - WORKS
|
||||
✅ 'r' to refresh - WORKS
|
||||
✅ 'o' to open address bar - WORKS
|
||||
```
|
||||
|
||||
Successfully browses:
|
||||
- https://tldp.org/HOWTO/HOWTO-INDEX/howtos.html ⭐ FULLY INTERACTIVE
|
||||
- https://example.com ⭐ FULLY INTERACTIVE
|
||||
- Any static HTML page ⭐ FULLY INTERACTIVE
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cmake -B build_ftxui -DCMAKE_PREFIX_PATH=/opt/homebrew
|
||||
cmake --build build_ftxui -j$(nproc)
|
||||
|
||||
# Test
|
||||
./test_browse.sh
|
||||
|
||||
# Try it
|
||||
./build_ftxui/tut https://example.com
|
||||
```
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
**THE BROWSER IS NOW FULLY INTERACTIVE AND USABLE!** 🎉
|
||||
|
||||
You can actually browse the web with TUT:
|
||||
- Load pages via HTTP/HTTPS
|
||||
- Scroll content with vim-style keys
|
||||
- Navigate between links with Tab or numbers
|
||||
- Follow links by pressing Enter
|
||||
- Go back in history with Backspace
|
||||
- Enter new URLs with 'o' key
|
||||
- See real-time load stats
|
||||
|
||||
The core experience is complete! Remaining work is mostly enhancements:
|
||||
- Search within pages
|
||||
- Persistent bookmarks and history
|
||||
- Form support
|
||||
- Better styling
|
||||
|
||||
See [KEYBOARD.md](KEYBOARD.md) for complete keyboard shortcuts reference.
|
||||
63
assets/config.toml
Normal file
63
assets/config.toml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# TUT Configuration File
|
||||
# Place in ~/.config/tut/config.toml
|
||||
|
||||
[general]
|
||||
# Default theme name (must exist in themes directory)
|
||||
theme = "default"
|
||||
|
||||
# Default homepage URL
|
||||
homepage = "about:blank"
|
||||
|
||||
# Enable debug logging
|
||||
debug = false
|
||||
|
||||
[browser]
|
||||
# HTTP request timeout in seconds
|
||||
timeout = 30
|
||||
|
||||
# Maximum redirects to follow
|
||||
max_redirects = 10
|
||||
|
||||
# User agent string
|
||||
user_agent = "TUT/0.1.0 (Terminal Browser)"
|
||||
|
||||
# Enable cookies
|
||||
cookies = true
|
||||
|
||||
[cache]
|
||||
# Enable page caching
|
||||
enabled = true
|
||||
|
||||
# Maximum cache size in MB
|
||||
max_size = 100
|
||||
|
||||
# Cache expiration in minutes
|
||||
expiration = 60
|
||||
|
||||
[history]
|
||||
# Maximum history entries
|
||||
max_entries = 1000
|
||||
|
||||
# Save history on exit
|
||||
save_on_exit = true
|
||||
|
||||
[bookmarks]
|
||||
# Auto-save bookmarks
|
||||
auto_save = true
|
||||
|
||||
[ui]
|
||||
# Show line numbers in content
|
||||
line_numbers = false
|
||||
|
||||
# Wrap long lines
|
||||
word_wrap = true
|
||||
|
||||
# Show images as ASCII art (requires stb_image)
|
||||
show_images = true
|
||||
|
||||
# Status bar position (top, bottom)
|
||||
status_position = "bottom"
|
||||
|
||||
[keybindings]
|
||||
# Keybinding preset (default, vim, emacs)
|
||||
preset = "default"
|
||||
48
assets/keybindings/default.toml
Normal file
48
assets/keybindings/default.toml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# TUT Default Keybindings
|
||||
# Vim-style navigation with function key shortcuts
|
||||
|
||||
[navigation]
|
||||
scroll_up = ["k", "Up"]
|
||||
scroll_down = ["j", "Down"]
|
||||
page_up = ["b", "Shift+Space"]
|
||||
page_down = ["Space"]
|
||||
top = ["g"]
|
||||
bottom = ["G"]
|
||||
back = ["Backspace", "Alt+Left"]
|
||||
forward = ["Alt+Right"]
|
||||
refresh = ["r", "F5"]
|
||||
|
||||
[links]
|
||||
next_link = ["Tab"]
|
||||
prev_link = ["Shift+Tab"]
|
||||
follow_link = ["Enter"]
|
||||
link_1 = ["1"]
|
||||
link_2 = ["2"]
|
||||
link_3 = ["3"]
|
||||
link_4 = ["4"]
|
||||
link_5 = ["5"]
|
||||
link_6 = ["6"]
|
||||
link_7 = ["7"]
|
||||
link_8 = ["8"]
|
||||
link_9 = ["9"]
|
||||
|
||||
[search]
|
||||
start_search = ["/"]
|
||||
next_result = ["n"]
|
||||
prev_result = ["N"]
|
||||
|
||||
[ui]
|
||||
focus_address_bar = ["Ctrl+l"]
|
||||
show_help = ["F1", "?"]
|
||||
show_bookmarks = ["F2"]
|
||||
show_history = ["F3"]
|
||||
show_settings = ["F4"]
|
||||
add_bookmark = ["Ctrl+d"]
|
||||
quit = ["Ctrl+q", "F10", "q"]
|
||||
|
||||
[forms]
|
||||
focus_form = ["i"]
|
||||
next_field = ["Tab"]
|
||||
prev_field = ["Shift+Tab"]
|
||||
submit = ["Enter"]
|
||||
cancel = ["Escape"]
|
||||
23
assets/themes/default.toml
Normal file
23
assets/themes/default.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# TUT Default Theme (Dark)
|
||||
# Inspired by btop's dark theme
|
||||
|
||||
[meta]
|
||||
name = "default"
|
||||
description = "Default dark theme with btop-style colors"
|
||||
|
||||
[colors]
|
||||
background = "#1e1e2e"
|
||||
foreground = "#cdd6f4"
|
||||
accent = "#89b4fa"
|
||||
border = "#45475a"
|
||||
selection = "#313244"
|
||||
link = "#74c7ec"
|
||||
visited_link = "#b4befe"
|
||||
error = "#f38ba8"
|
||||
success = "#a6e3a1"
|
||||
warning = "#f9e2af"
|
||||
|
||||
[ui]
|
||||
border_style = "rounded"
|
||||
show_shadows = false
|
||||
transparency = false
|
||||
23
assets/themes/gruvbox.toml
Normal file
23
assets/themes/gruvbox.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# TUT Gruvbox Theme
|
||||
# Based on Gruvbox Dark color palette
|
||||
|
||||
[meta]
|
||||
name = "gruvbox"
|
||||
description = "Gruvbox dark color palette theme"
|
||||
|
||||
[colors]
|
||||
background = "#282828"
|
||||
foreground = "#ebdbb2"
|
||||
accent = "#fabd2f"
|
||||
border = "#504945"
|
||||
selection = "#3c3836"
|
||||
link = "#83a598"
|
||||
visited_link = "#d3869b"
|
||||
error = "#fb4934"
|
||||
success = "#b8bb26"
|
||||
warning = "#fe8019"
|
||||
|
||||
[ui]
|
||||
border_style = "rounded"
|
||||
show_shadows = false
|
||||
transparency = false
|
||||
23
assets/themes/nord.toml
Normal file
23
assets/themes/nord.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# TUT Nord Theme
|
||||
# Based on Nord color palette
|
||||
|
||||
[meta]
|
||||
name = "nord"
|
||||
description = "Nord color palette theme"
|
||||
|
||||
[colors]
|
||||
background = "#2e3440"
|
||||
foreground = "#d8dee9"
|
||||
accent = "#88c0d0"
|
||||
border = "#4c566a"
|
||||
selection = "#434c5e"
|
||||
link = "#81a1c1"
|
||||
visited_link = "#b48ead"
|
||||
error = "#bf616a"
|
||||
success = "#a3be8c"
|
||||
warning = "#ebcb8b"
|
||||
|
||||
[ui]
|
||||
border_style = "rounded"
|
||||
show_shadows = false
|
||||
transparency = false
|
||||
23
assets/themes/solarized-dark.toml
Normal file
23
assets/themes/solarized-dark.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# TUT Solarized Dark Theme
|
||||
# Based on Solarized Dark color palette
|
||||
|
||||
[meta]
|
||||
name = "solarized-dark"
|
||||
description = "Solarized dark color palette theme"
|
||||
|
||||
[colors]
|
||||
background = "#002b36"
|
||||
foreground = "#839496"
|
||||
accent = "#268bd2"
|
||||
border = "#073642"
|
||||
selection = "#073642"
|
||||
link = "#2aa198"
|
||||
visited_link = "#6c71c4"
|
||||
error = "#dc322f"
|
||||
success = "#859900"
|
||||
warning = "#b58900"
|
||||
|
||||
[ui]
|
||||
border_style = "rounded"
|
||||
show_shadows = false
|
||||
transparency = false
|
||||
42
cmake/version.hpp.in
Normal file
42
cmake/version.hpp.in
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* @file version.hpp
|
||||
* @brief TUT 版本信息
|
||||
*
|
||||
* 此文件由 CMake 自动生成,请勿手动修改
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
namespace tut {
|
||||
|
||||
/// 主版本号
|
||||
constexpr int VERSION_MAJOR = @PROJECT_VERSION_MAJOR@;
|
||||
|
||||
/// 次版本号
|
||||
constexpr int VERSION_MINOR = @PROJECT_VERSION_MINOR@;
|
||||
|
||||
/// 补丁版本号
|
||||
constexpr int VERSION_PATCH = @PROJECT_VERSION_PATCH@;
|
||||
|
||||
/// 完整版本字符串
|
||||
constexpr const char* VERSION_STRING = "@PROJECT_VERSION@";
|
||||
|
||||
/// 项目名称
|
||||
constexpr const char* PROJECT_NAME = "@PROJECT_NAME@";
|
||||
|
||||
/// 项目描述
|
||||
constexpr const char* PROJECT_DESCRIPTION = "@PROJECT_DESCRIPTION@";
|
||||
|
||||
/// 项目主页
|
||||
constexpr const char* PROJECT_HOMEPAGE = "@PROJECT_HOMEPAGE_URL@";
|
||||
|
||||
/// 构建类型
|
||||
constexpr const char* BUILD_TYPE = "@CMAKE_BUILD_TYPE@";
|
||||
|
||||
/// 编译器 ID
|
||||
constexpr const char* COMPILER_ID = "@CMAKE_CXX_COMPILER_ID@";
|
||||
|
||||
/// 编译器版本
|
||||
constexpr const char* COMPILER_VERSION = "@CMAKE_CXX_COMPILER_VERSION@";
|
||||
|
||||
} // namespace tut
|
||||
248
src/bookmark.cpp
248
src/bookmark.cpp
|
|
@ -1,248 +0,0 @@
|
|||
#include "bookmark.h"
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <sys/stat.h>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace tut {
|
||||
|
||||
BookmarkManager::BookmarkManager() {
|
||||
load();
|
||||
}
|
||||
|
||||
BookmarkManager::~BookmarkManager() {
|
||||
save();
|
||||
}
|
||||
|
||||
std::string BookmarkManager::get_config_dir() {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home) {
|
||||
home = "/tmp";
|
||||
}
|
||||
return std::string(home) + "/.config/tut";
|
||||
}
|
||||
|
||||
std::string BookmarkManager::get_bookmarks_path() {
|
||||
return get_config_dir() + "/bookmarks.json";
|
||||
}
|
||||
|
||||
bool BookmarkManager::ensure_config_dir() {
|
||||
std::string dir = get_config_dir();
|
||||
|
||||
// 检查目录是否存在
|
||||
struct stat st;
|
||||
if (stat(dir.c_str(), &st) == 0) {
|
||||
return S_ISDIR(st.st_mode);
|
||||
}
|
||||
|
||||
// 创建 ~/.config 目录
|
||||
std::string config_dir = std::string(std::getenv("HOME") ? std::getenv("HOME") : "/tmp") + "/.config";
|
||||
mkdir(config_dir.c_str(), 0755);
|
||||
|
||||
// 创建 ~/.config/tut 目录
|
||||
return mkdir(dir.c_str(), 0755) == 0 || errno == EEXIST;
|
||||
}
|
||||
|
||||
// 简单的 JSON 转义
|
||||
static std::string json_escape(const std::string& s) {
|
||||
std::string result;
|
||||
result.reserve(s.size() + 10);
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': result += "\\\""; break;
|
||||
case '\\': result += "\\\\"; break;
|
||||
case '\n': result += "\\n"; break;
|
||||
case '\r': result += "\\r"; break;
|
||||
case '\t': result += "\\t"; break;
|
||||
default: result += c; break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 简单的 JSON 反转义
|
||||
static std::string json_unescape(const std::string& s) {
|
||||
std::string result;
|
||||
result.reserve(s.size());
|
||||
for (size_t i = 0; i < s.size(); ++i) {
|
||||
if (s[i] == '\\' && i + 1 < s.size()) {
|
||||
switch (s[i + 1]) {
|
||||
case '"': result += '"'; ++i; break;
|
||||
case '\\': result += '\\'; ++i; break;
|
||||
case 'n': result += '\n'; ++i; break;
|
||||
case 'r': result += '\r'; ++i; break;
|
||||
case 't': result += '\t'; ++i; break;
|
||||
default: result += s[i]; break;
|
||||
}
|
||||
} else {
|
||||
result += s[i];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool BookmarkManager::load() {
|
||||
bookmarks_.clear();
|
||||
|
||||
std::ifstream file(get_bookmarks_path());
|
||||
if (!file) {
|
||||
return false; // 文件不存在,这是正常的
|
||||
}
|
||||
|
||||
std::string content((std::istreambuf_iterator<char>(file)),
|
||||
std::istreambuf_iterator<char>());
|
||||
file.close();
|
||||
|
||||
// 简单的 JSON 解析
|
||||
// 格式: [{"url":"...","title":"...","time":123}, ...]
|
||||
size_t pos = content.find('[');
|
||||
if (pos == std::string::npos) return false;
|
||||
|
||||
pos++; // 跳过 '['
|
||||
|
||||
while (pos < content.size()) {
|
||||
// 查找对象开始
|
||||
pos = content.find('{', pos);
|
||||
if (pos == std::string::npos) break;
|
||||
pos++;
|
||||
|
||||
Bookmark bm;
|
||||
|
||||
// 解析字段
|
||||
while (pos < content.size() && content[pos] != '}') {
|
||||
// 跳过空白
|
||||
while (pos < content.size() && (content[pos] == ' ' || content[pos] == '\n' ||
|
||||
content[pos] == '\r' || content[pos] == '\t' || content[pos] == ',')) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (content[pos] == '}') break;
|
||||
|
||||
// 读取键名
|
||||
if (content[pos] != '"') { pos++; continue; }
|
||||
pos++; // 跳过 '"'
|
||||
|
||||
size_t key_end = content.find('"', pos);
|
||||
if (key_end == std::string::npos) break;
|
||||
std::string key = content.substr(pos, key_end - pos);
|
||||
pos = key_end + 1;
|
||||
|
||||
// 跳过 ':'
|
||||
pos = content.find(':', pos);
|
||||
if (pos == std::string::npos) break;
|
||||
pos++;
|
||||
|
||||
// 跳过空白
|
||||
while (pos < content.size() && (content[pos] == ' ' || content[pos] == '\n' ||
|
||||
content[pos] == '\r' || content[pos] == '\t')) {
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (content[pos] == '"') {
|
||||
// 字符串值
|
||||
pos++; // 跳过 '"'
|
||||
size_t val_end = pos;
|
||||
while (val_end < content.size()) {
|
||||
if (content[val_end] == '"' && content[val_end - 1] != '\\') break;
|
||||
val_end++;
|
||||
}
|
||||
std::string value = json_unescape(content.substr(pos, val_end - pos));
|
||||
pos = val_end + 1;
|
||||
|
||||
if (key == "url") bm.url = value;
|
||||
else if (key == "title") bm.title = value;
|
||||
} else {
|
||||
// 数字值
|
||||
size_t val_end = pos;
|
||||
while (val_end < content.size() && content[val_end] >= '0' && content[val_end] <= '9') {
|
||||
val_end++;
|
||||
}
|
||||
std::string value = content.substr(pos, val_end - pos);
|
||||
pos = val_end;
|
||||
|
||||
if (key == "time") {
|
||||
bm.added_time = std::stoll(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bm.url.empty()) {
|
||||
bookmarks_.push_back(bm);
|
||||
}
|
||||
|
||||
// 跳到下一个对象
|
||||
pos = content.find('}', pos);
|
||||
if (pos == std::string::npos) break;
|
||||
pos++;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookmarkManager::save() const {
|
||||
if (!ensure_config_dir()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ofstream file(get_bookmarks_path());
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file << "[\n";
|
||||
for (size_t i = 0; i < bookmarks_.size(); ++i) {
|
||||
const auto& bm = bookmarks_[i];
|
||||
file << " {\n";
|
||||
file << " \"url\": \"" << json_escape(bm.url) << "\",\n";
|
||||
file << " \"title\": \"" << json_escape(bm.title) << "\",\n";
|
||||
file << " \"time\": " << bm.added_time << "\n";
|
||||
file << " }";
|
||||
if (i + 1 < bookmarks_.size()) {
|
||||
file << ",";
|
||||
}
|
||||
file << "\n";
|
||||
}
|
||||
file << "]\n";
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookmarkManager::add(const std::string& url, const std::string& title) {
|
||||
// 检查是否已存在
|
||||
if (contains(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bookmarks_.emplace_back(url, title);
|
||||
return save();
|
||||
}
|
||||
|
||||
bool BookmarkManager::remove(const std::string& url) {
|
||||
auto it = std::find_if(bookmarks_.begin(), bookmarks_.end(),
|
||||
[&url](const Bookmark& bm) { return bm.url == url; });
|
||||
|
||||
if (it == bookmarks_.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bookmarks_.erase(it);
|
||||
return save();
|
||||
}
|
||||
|
||||
bool BookmarkManager::remove_at(size_t index) {
|
||||
if (index >= bookmarks_.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bookmarks_.erase(bookmarks_.begin() + index);
|
||||
return save();
|
||||
}
|
||||
|
||||
bool BookmarkManager::contains(const std::string& url) const {
|
||||
return std::find_if(bookmarks_.begin(), bookmarks_.end(),
|
||||
[&url](const Bookmark& bm) { return bm.url == url; })
|
||||
!= bookmarks_.end();
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <ctime>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* 书签条目
|
||||
*/
|
||||
struct Bookmark {
|
||||
std::string url;
|
||||
std::string title;
|
||||
std::time_t added_time;
|
||||
|
||||
Bookmark() : added_time(0) {}
|
||||
Bookmark(const std::string& url, const std::string& title)
|
||||
: url(url), title(title), added_time(std::time(nullptr)) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* 书签管理器
|
||||
*
|
||||
* 书签存储在 ~/.config/tut/bookmarks.json
|
||||
*/
|
||||
class BookmarkManager {
|
||||
public:
|
||||
BookmarkManager();
|
||||
~BookmarkManager();
|
||||
|
||||
/**
|
||||
* 加载书签(从默认路径)
|
||||
*/
|
||||
bool load();
|
||||
|
||||
/**
|
||||
* 保存书签(到默认路径)
|
||||
*/
|
||||
bool save() const;
|
||||
|
||||
/**
|
||||
* 添加书签
|
||||
* @return true 如果添加成功,false 如果已存在
|
||||
*/
|
||||
bool add(const std::string& url, const std::string& title);
|
||||
|
||||
/**
|
||||
* 删除书签
|
||||
* @return true 如果删除成功
|
||||
*/
|
||||
bool remove(const std::string& url);
|
||||
|
||||
/**
|
||||
* 删除书签(按索引)
|
||||
*/
|
||||
bool remove_at(size_t index);
|
||||
|
||||
/**
|
||||
* 检查URL是否已收藏
|
||||
*/
|
||||
bool contains(const std::string& url) const;
|
||||
|
||||
/**
|
||||
* 获取书签列表
|
||||
*/
|
||||
const std::vector<Bookmark>& get_all() const { return bookmarks_; }
|
||||
|
||||
/**
|
||||
* 获取书签数量
|
||||
*/
|
||||
size_t count() const { return bookmarks_.size(); }
|
||||
|
||||
/**
|
||||
* 清空所有书签
|
||||
*/
|
||||
void clear() { bookmarks_.clear(); }
|
||||
|
||||
/**
|
||||
* 获取配置目录路径
|
||||
*/
|
||||
static std::string get_config_dir();
|
||||
|
||||
/**
|
||||
* 获取书签文件路径
|
||||
*/
|
||||
static std::string get_bookmarks_path();
|
||||
|
||||
private:
|
||||
std::vector<Bookmark> bookmarks_;
|
||||
|
||||
// 确保配置目录存在
|
||||
static bool ensure_config_dir();
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
615
src/browser.cpp
615
src/browser.cpp
|
|
@ -1,615 +0,0 @@
|
|||
#include "browser.h"
|
||||
#include "dom_tree.h"
|
||||
#include <curses.h>
|
||||
#include <clocale>
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <map>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
|
||||
class Browser::Impl {
|
||||
public:
|
||||
HttpClient http_client;
|
||||
HtmlParser html_parser;
|
||||
TextRenderer renderer;
|
||||
InputHandler input_handler;
|
||||
|
||||
DocumentTree current_tree;
|
||||
std::vector<RenderedLine> rendered_lines;
|
||||
std::string current_url;
|
||||
std::vector<std::string> history;
|
||||
int history_pos = -1;
|
||||
|
||||
int scroll_pos = 0;
|
||||
std::string status_message;
|
||||
std::string search_term;
|
||||
std::vector<int> search_results;
|
||||
|
||||
int screen_height = 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() {
|
||||
setlocale(LC_ALL, "");
|
||||
initscr();
|
||||
init_color_scheme();
|
||||
cbreak();
|
||||
noecho();
|
||||
keypad(stdscr, TRUE);
|
||||
curs_set(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);
|
||||
}
|
||||
|
||||
void cleanup_screen() {
|
||||
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) {
|
||||
status_message = "Loading " + url + "...";
|
||||
draw_screen();
|
||||
refresh();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
current_tree = html_parser.parse_tree(response.body, url);
|
||||
rendered_lines = renderer.render_tree(current_tree, screen_width);
|
||||
build_interactive_list();
|
||||
|
||||
current_url = url;
|
||||
scroll_pos = 0;
|
||||
current_element_index = interactive_elements.empty() ? -1 : 0;
|
||||
search_results.clear();
|
||||
|
||||
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;
|
||||
|
||||
status_message = current_tree.title.empty() ? url : current_tree.title;
|
||||
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() {
|
||||
attron(COLOR_PAIR(COLOR_STATUS_BAR));
|
||||
mvprintw(screen_height - 1, 0, "%s", std::string(screen_width, ' ').c_str());
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
mvprintw(screen_height - 1, 0, " %s", mode_str.c_str());
|
||||
|
||||
if (mode == InputMode::NORMAL) {
|
||||
std::string display_msg;
|
||||
|
||||
// Priority: Hovered Link URL > Status Message > Title
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int total_lines = rendered_lines.size();
|
||||
int percentage = (total_lines > 0 && scroll_pos + screen_height - 2 < 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) + "%";
|
||||
mvprintw(screen_height - 1, screen_width - pos_str.length() - 1, "%s", pos_str.c_str());
|
||||
|
||||
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() {
|
||||
clear();
|
||||
|
||||
int visible_lines = screen_height - 2;
|
||||
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) {
|
||||
int line_idx = scroll_pos + i;
|
||||
const auto& line = rendered_lines[line_idx];
|
||||
|
||||
// Check if this line is in search results
|
||||
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));
|
||||
} else if (is_interactive) {
|
||||
attron(COLOR_PAIR(COLOR_LINK));
|
||||
attron(A_UNDERLINE);
|
||||
} else {
|
||||
attron(COLOR_PAIR(line.color_pair));
|
||||
if (line.is_bold) attron(A_BOLD);
|
||||
}
|
||||
|
||||
if (in_search_results) attron(A_REVERSE);
|
||||
|
||||
// Print the UTF-8 sequence
|
||||
addnstr(line.text.c_str() + byte_idx, seq_len);
|
||||
|
||||
// Approximate column width update (simple)
|
||||
// For proper handling, we should use wcwidth, but for now assuming 1 or 2 based on seq_len is "okay" approximation for cursor placement
|
||||
// actually addnstr advances cursor, getyx is better?
|
||||
// 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 (in_search_results) attroff(A_REVERSE);
|
||||
|
||||
if (is_active) {
|
||||
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
|
||||
} else if (is_interactive) {
|
||||
attroff(A_UNDERLINE);
|
||||
attroff(COLOR_PAIR(COLOR_LINK));
|
||||
} else {
|
||||
if (line.is_bold) attroff(A_BOLD);
|
||||
attroff(COLOR_PAIR(line.color_pair));
|
||||
}
|
||||
|
||||
byte_idx += seq_len;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
int visible_lines = screen_height - 2;
|
||||
int max_scroll = std::max(0, static_cast<int>(rendered_lines.size()) - 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 (!interactive_elements.empty()) {
|
||||
current_element_index = (current_element_index + 1) % interactive_elements.size();
|
||||
scroll_to_element(current_element_index);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::PREV_LINK:
|
||||
if (!interactive_elements.empty()) {
|
||||
current_element_index = (current_element_index - 1 + interactive_elements.size()) % interactive_elements.size();
|
||||
scroll_to_element(current_element_index);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::FOLLOW_LINK:
|
||||
activate_element(current_element_index);
|
||||
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); break;
|
||||
|
||||
case Action::SEARCH_FORWARD:
|
||||
search_term = result.text;
|
||||
search_results.clear();
|
||||
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 (!search_results.empty()) {
|
||||
scroll_pos = search_results[0];
|
||||
status_message = "Found " + std::to_string(search_results.size()) + " matches";
|
||||
} else status_message = "Pattern not found";
|
||||
break;
|
||||
|
||||
case Action::SEARCH_NEXT:
|
||||
if (!search_results.empty()) {
|
||||
auto it = std::upper_bound(search_results.begin(), search_results.end(), scroll_pos);
|
||||
scroll_pos = (it != search_results.end()) ? *it : search_results[0];
|
||||
}
|
||||
break;
|
||||
case Action::SEARCH_PREV:
|
||||
if (!search_results.empty()) {
|
||||
auto it = std::lower_bound(search_results.begin(), search_results.end(), scroll_pos);
|
||||
scroll_pos = (it != search_results.begin()) ? *(--it) : search_results.back();
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::HELP: show_help(); break;
|
||||
case Action::QUIT: break; // Handled in browser.run
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void scroll_to_element(int index) {
|
||||
if (index < 0 || index >= static_cast<int>(interactive_elements.size())) return;
|
||||
|
||||
int line_idx = interactive_elements[index].line_index;
|
||||
int visible_lines = screen_height - 2;
|
||||
|
||||
if (line_idx < scroll_pos || line_idx >= scroll_pos + visible_lines) {
|
||||
scroll_pos = std::max(0, line_idx - visible_lines / 2);
|
||||
}
|
||||
}
|
||||
|
||||
void show_help() {
|
||||
// Updated help text would go here
|
||||
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>";
|
||||
current_tree = html_parser.parse_tree(help_html.str(), "help://");
|
||||
rendered_lines = renderer.render_tree(current_tree, screen_width);
|
||||
build_interactive_list();
|
||||
scroll_pos = 0;
|
||||
current_element_index = -1;
|
||||
}
|
||||
};
|
||||
|
||||
Browser::Browser() : pImpl(std::make_unique<Impl>()) {
|
||||
pImpl->input_handler.set_status_callback([this](const std::string& msg) {
|
||||
pImpl->status_message = msg;
|
||||
});
|
||||
}
|
||||
|
||||
Browser::~Browser() = default;
|
||||
|
||||
void Browser::run(const std::string& initial_url) {
|
||||
pImpl->init_screen();
|
||||
|
||||
if (!initial_url.empty()) load_url(initial_url);
|
||||
else pImpl->show_help();
|
||||
|
||||
bool running = true;
|
||||
while (running) {
|
||||
pImpl->draw_screen();
|
||||
refresh();
|
||||
|
||||
int ch = getch();
|
||||
if (ch == ERR) { napms(50); continue; }
|
||||
|
||||
if (ch == KEY_MOUSE) {
|
||||
MEVENT event;
|
||||
if (getmouse(&event) == OK) pImpl->handle_mouse(event);
|
||||
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 Browser::load_url(const std::string& url) {
|
||||
return pImpl->load_page(url);
|
||||
}
|
||||
|
||||
std::string Browser::get_current_url() const {
|
||||
return pImpl->current_url;
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "http_client.h"
|
||||
#include "html_parser.h"
|
||||
#include "text_renderer.h"
|
||||
#include "input_handler.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
class Browser {
|
||||
public:
|
||||
Browser();
|
||||
~Browser();
|
||||
|
||||
void run(const std::string& initial_url = "");
|
||||
bool load_url(const std::string& url);
|
||||
std::string get_current_url() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
|
@ -1,762 +0,0 @@
|
|||
#include "browser_v2.h"
|
||||
#include "dom_tree.h"
|
||||
#include "bookmark.h"
|
||||
#include "render/colors.h"
|
||||
#include "render/decorations.h"
|
||||
#include "render/image.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;
|
||||
tut::BookmarkManager bookmark_manager;
|
||||
|
||||
// 新渲染系统
|
||||
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;
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
load_images(current_tree);
|
||||
|
||||
// 布局计算
|
||||
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);
|
||||
}
|
||||
|
||||
// 下载并解码页面中的图片
|
||||
void load_images(DocumentTree& tree) {
|
||||
if (tree.images.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
int total = static_cast<int>(tree.images.size());
|
||||
|
||||
for (DomNode* img_node : tree.images) {
|
||||
if (img_node->img_src.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
loaded++;
|
||||
status_message = "🖼 Loading image " + std::to_string(loaded) + "/" + std::to_string(total) + "...";
|
||||
draw_screen();
|
||||
|
||||
// 下载图片
|
||||
auto response = http_client.fetch_binary(img_node->img_src);
|
||||
if (!response.is_success() || response.data.empty()) {
|
||||
continue; // 跳过失败的图片
|
||||
}
|
||||
|
||||
// 解码图片
|
||||
tut::ImageData img_data = tut::ImageRenderer::load_from_memory(response.data);
|
||||
if (img_data.is_valid()) {
|
||||
img_node->image_data = std::move(img_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从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::ADD_BOOKMARK:
|
||||
add_bookmark();
|
||||
break;
|
||||
|
||||
case Action::REMOVE_BOOKMARK:
|
||||
remove_bookmark();
|
||||
break;
|
||||
|
||||
case Action::SHOW_BOOKMARKS:
|
||||
show_bookmarks();
|
||||
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>Bookmarks</h2>
|
||||
<ul>
|
||||
<li>B - Add bookmark</li>
|
||||
<li>D - Remove bookmark</li>
|
||||
<li>:bookmarks - Show bookmarks</li>
|
||||
</ul>
|
||||
|
||||
<h2>Commands</h2>
|
||||
<ul>
|
||||
<li>:o URL - Open URL</li>
|
||||
<li>:bookmarks - Show bookmarks</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";
|
||||
}
|
||||
|
||||
void show_bookmarks() {
|
||||
std::ostringstream html;
|
||||
html << R"(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Bookmarks</title></head>
|
||||
<body>
|
||||
<h1>Bookmarks</h1>
|
||||
)";
|
||||
|
||||
const auto& bookmarks = bookmark_manager.get_all();
|
||||
|
||||
if (bookmarks.empty()) {
|
||||
html << "<p>No bookmarks yet.</p>\n";
|
||||
html << "<p>Press <b>B</b> on any page to add a bookmark.</p>\n";
|
||||
} else {
|
||||
html << "<ul>\n";
|
||||
for (const auto& bm : bookmarks) {
|
||||
html << "<li><a href=\"" << bm.url << "\">"
|
||||
<< (bm.title.empty() ? bm.url : bm.title)
|
||||
<< "</a></li>\n";
|
||||
}
|
||||
html << "</ul>\n";
|
||||
html << "<hr>\n";
|
||||
html << "<p>" << bookmarks.size() << " bookmark(s). Press D on any page to remove its bookmark.</p>\n";
|
||||
}
|
||||
|
||||
html << R"(
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
current_tree = html_parser.parse_tree(html.str(), "bookmarks://");
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
scroll_pos = 0;
|
||||
active_link = current_tree.links.empty() ? -1 : 0;
|
||||
status_message = "Bookmarks";
|
||||
}
|
||||
|
||||
void add_bookmark() {
|
||||
if (current_url.empty() || current_url.find("://") == std::string::npos) {
|
||||
status_message = "Cannot bookmark this page";
|
||||
return;
|
||||
}
|
||||
|
||||
// 不要书签特殊页面
|
||||
if (current_url.find("help://") == 0 || current_url.find("bookmarks://") == 0) {
|
||||
status_message = "Cannot bookmark special pages";
|
||||
return;
|
||||
}
|
||||
|
||||
std::string title = current_tree.title.empty() ? current_url : current_tree.title;
|
||||
|
||||
if (bookmark_manager.add(current_url, title)) {
|
||||
status_message = "Bookmarked: " + title;
|
||||
} else {
|
||||
status_message = "Already bookmarked";
|
||||
}
|
||||
}
|
||||
|
||||
void remove_bookmark() {
|
||||
if (current_url.empty()) {
|
||||
status_message = "No page to unbookmark";
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookmark_manager.remove(current_url)) {
|
||||
status_message = "Bookmark removed";
|
||||
} else {
|
||||
status_message = "Not bookmarked";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
200
src/core/bookmark_manager.cpp
Normal file
200
src/core/bookmark_manager.cpp
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* @file bookmark_manager.cpp
|
||||
* @brief Bookmark manager implementation
|
||||
*/
|
||||
|
||||
#include "core/bookmark_manager.hpp"
|
||||
#include "utils/logger.hpp"
|
||||
#include "utils/config.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
namespace tut {
|
||||
|
||||
class BookmarkManager::Impl {
|
||||
public:
|
||||
std::vector<Bookmark> bookmarks_;
|
||||
std::string filepath_;
|
||||
|
||||
Impl() {
|
||||
// Get bookmark file path
|
||||
Config& config = Config::instance();
|
||||
std::string config_dir = config.getConfigPath();
|
||||
filepath_ = config_dir + "/bookmarks.json";
|
||||
|
||||
// Ensure config directory exists
|
||||
mkdir(config_dir.c_str(), 0755);
|
||||
|
||||
// Load existing bookmarks
|
||||
load();
|
||||
}
|
||||
|
||||
void load() {
|
||||
std::ifstream file(filepath_);
|
||||
if (!file.is_open()) {
|
||||
LOG_DEBUG << "No bookmark file found, starting fresh";
|
||||
return;
|
||||
}
|
||||
|
||||
bookmarks_.clear();
|
||||
std::string line;
|
||||
|
||||
// Skip opening brace
|
||||
std::getline(file, line);
|
||||
|
||||
while (std::getline(file, line)) {
|
||||
// Skip closing brace and empty lines
|
||||
if (line.find('}') != std::string::npos || line.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Simple JSON parsing for bookmark entries
|
||||
// Format: {"title": "...", "url": "...", "timestamp": 123}
|
||||
if (line.find("\"title\"") != std::string::npos) {
|
||||
Bookmark bookmark;
|
||||
|
||||
// Parse title
|
||||
size_t title_start = line.find("\"title\"") + 10;
|
||||
size_t title_end = line.find("\"", title_start);
|
||||
if (title_start != std::string::npos && title_end != std::string::npos) {
|
||||
bookmark.title = line.substr(title_start, title_end - title_start);
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
size_t url_start = line.find("\"url\"") + 8;
|
||||
size_t url_end = line.find("\"", url_start);
|
||||
if (url_start != std::string::npos && url_end != std::string::npos) {
|
||||
bookmark.url = line.substr(url_start, url_end - url_start);
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
size_t ts_start = line.find("\"timestamp\"") + 13;
|
||||
size_t ts_end = line.find_first_of(",}", ts_start);
|
||||
if (ts_start != std::string::npos && ts_end != std::string::npos) {
|
||||
std::string ts_str = line.substr(ts_start, ts_end - ts_start);
|
||||
try {
|
||||
bookmark.timestamp = std::stoll(ts_str);
|
||||
} catch (...) {
|
||||
bookmark.timestamp = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bookmark.url.empty()) {
|
||||
bookmarks_.push_back(bookmark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOG_INFO << "Loaded " << bookmarks_.size() << " bookmarks";
|
||||
}
|
||||
|
||||
void save() {
|
||||
std::ofstream file(filepath_);
|
||||
if (!file.is_open()) {
|
||||
LOG_ERROR << "Failed to save bookmarks to " << filepath_;
|
||||
return;
|
||||
}
|
||||
|
||||
file << "[\n";
|
||||
for (size_t i = 0; i < bookmarks_.size(); ++i) {
|
||||
const auto& bm = bookmarks_[i];
|
||||
file << " {\"title\": \"" << escapeJson(bm.title)
|
||||
<< "\", \"url\": \"" << escapeJson(bm.url)
|
||||
<< "\", \"timestamp\": " << bm.timestamp << "}";
|
||||
if (i < bookmarks_.size() - 1) {
|
||||
file << ",";
|
||||
}
|
||||
file << "\n";
|
||||
}
|
||||
file << "]\n";
|
||||
|
||||
LOG_DEBUG << "Saved " << bookmarks_.size() << " bookmarks";
|
||||
}
|
||||
|
||||
std::string escapeJson(const std::string& str) {
|
||||
std::string result;
|
||||
for (char c : str) {
|
||||
if (c == '"') {
|
||||
result += "\\\"";
|
||||
} else if (c == '\\') {
|
||||
result += "\\\\";
|
||||
} else if (c == '\n') {
|
||||
result += "\\n";
|
||||
} else if (c == '\t') {
|
||||
result += "\\t";
|
||||
} else {
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int64_t getCurrentTimestamp() {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto duration = now.time_since_epoch();
|
||||
return std::chrono::duration_cast<std::chrono::seconds>(duration).count();
|
||||
}
|
||||
};
|
||||
|
||||
BookmarkManager::BookmarkManager() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
BookmarkManager::~BookmarkManager() = default;
|
||||
|
||||
bool BookmarkManager::add(const std::string& title, const std::string& url) {
|
||||
// Check if already exists
|
||||
if (contains(url)) {
|
||||
LOG_DEBUG << "Bookmark already exists: " << url;
|
||||
return false;
|
||||
}
|
||||
|
||||
Bookmark bookmark(title, url, impl_->getCurrentTimestamp());
|
||||
impl_->bookmarks_.push_back(bookmark);
|
||||
impl_->save();
|
||||
|
||||
LOG_INFO << "Added bookmark: " << title << " (" << url << ")";
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookmarkManager::remove(const std::string& url) {
|
||||
auto it = std::find_if(impl_->bookmarks_.begin(), impl_->bookmarks_.end(),
|
||||
[&url](const Bookmark& bm) { return bm.url == url; });
|
||||
|
||||
if (it == impl_->bookmarks_.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
impl_->bookmarks_.erase(it);
|
||||
impl_->save();
|
||||
|
||||
LOG_INFO << "Removed bookmark: " << url;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookmarkManager::contains(const std::string& url) const {
|
||||
return std::find_if(impl_->bookmarks_.begin(), impl_->bookmarks_.end(),
|
||||
[&url](const Bookmark& bm) { return bm.url == url; }) !=
|
||||
impl_->bookmarks_.end();
|
||||
}
|
||||
|
||||
std::vector<Bookmark> BookmarkManager::getAll() const {
|
||||
// Return sorted by timestamp (newest first)
|
||||
std::vector<Bookmark> result = impl_->bookmarks_;
|
||||
std::sort(result.begin(), result.end(),
|
||||
[](const Bookmark& a, const Bookmark& b) {
|
||||
return a.timestamp > b.timestamp;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
void BookmarkManager::clear() {
|
||||
impl_->bookmarks_.clear();
|
||||
impl_->save();
|
||||
LOG_INFO << "Cleared all bookmarks";
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
72
src/core/bookmark_manager.hpp
Normal file
72
src/core/bookmark_manager.hpp
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* @file bookmark_manager.hpp
|
||||
* @brief Bookmark manager for persistent storage
|
||||
* @author m1ngsama
|
||||
* @date 2025-01-01
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief Bookmark entry
|
||||
*/
|
||||
struct Bookmark {
|
||||
std::string title;
|
||||
std::string url;
|
||||
int64_t timestamp{0}; // Unix timestamp
|
||||
|
||||
Bookmark() = default;
|
||||
Bookmark(const std::string& t, const std::string& u, int64_t ts = 0)
|
||||
: title(t), url(u), timestamp(ts) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Bookmark manager with JSON persistence
|
||||
*
|
||||
* Manages bookmarks with automatic persistence to
|
||||
* ~/.config/tut/bookmarks.json
|
||||
*/
|
||||
class BookmarkManager {
|
||||
public:
|
||||
BookmarkManager();
|
||||
~BookmarkManager();
|
||||
|
||||
/**
|
||||
* @brief Add a bookmark
|
||||
* @return true if added, false if already exists
|
||||
*/
|
||||
bool add(const std::string& title, const std::string& url);
|
||||
|
||||
/**
|
||||
* @brief Remove a bookmark by URL
|
||||
* @return true if removed, false if not found
|
||||
*/
|
||||
bool remove(const std::string& url);
|
||||
|
||||
/**
|
||||
* @brief Check if URL is bookmarked
|
||||
*/
|
||||
bool contains(const std::string& url) const;
|
||||
|
||||
/**
|
||||
* @brief Get all bookmarks (sorted by timestamp, newest first)
|
||||
*/
|
||||
std::vector<Bookmark> getAll() const;
|
||||
|
||||
/**
|
||||
* @brief Clear all bookmarks
|
||||
*/
|
||||
void clear();
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
133
src/core/browser_engine.cpp
Normal file
133
src/core/browser_engine.cpp
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* @file browser_engine.cpp
|
||||
* @brief 浏览器引擎实现
|
||||
*/
|
||||
|
||||
#include "core/browser_engine.hpp"
|
||||
#include "core/http_client.hpp"
|
||||
#include "renderer/html_renderer.hpp"
|
||||
|
||||
namespace tut {
|
||||
|
||||
class BrowserEngine::Impl {
|
||||
public:
|
||||
std::string current_url_;
|
||||
std::string title_;
|
||||
std::string content_;
|
||||
std::vector<LinkInfo> links_;
|
||||
std::vector<std::string> history_;
|
||||
size_t history_index_{0};
|
||||
|
||||
HttpClient http_client_;
|
||||
HtmlRenderer renderer_;
|
||||
};
|
||||
|
||||
BrowserEngine::BrowserEngine() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
BrowserEngine::~BrowserEngine() = default;
|
||||
|
||||
bool BrowserEngine::loadUrl(const std::string& url) {
|
||||
impl_->current_url_ = url;
|
||||
|
||||
// 发送 HTTP 请求
|
||||
auto response = impl_->http_client_.get(url);
|
||||
|
||||
if (!response.isSuccess()) {
|
||||
// 加载失败,设置错误内容
|
||||
impl_->title_ = "Error";
|
||||
impl_->content_ = "Failed to load page: " +
|
||||
(response.error.empty()
|
||||
? "HTTP " + std::to_string(response.status_code)
|
||||
: response.error);
|
||||
impl_->links_.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 渲染 HTML
|
||||
return loadHtml(response.body);
|
||||
}
|
||||
|
||||
bool BrowserEngine::loadHtml(const std::string& html) {
|
||||
// 渲染 HTML
|
||||
RenderOptions options;
|
||||
options.show_links = true;
|
||||
options.use_colors = true;
|
||||
|
||||
auto result = impl_->renderer_.render(html, options);
|
||||
|
||||
impl_->title_ = result.title;
|
||||
impl_->content_ = result.text;
|
||||
impl_->links_ = result.links;
|
||||
|
||||
// 解析相对 URL
|
||||
if (!impl_->current_url_.empty()) {
|
||||
for (auto& link : impl_->links_) {
|
||||
if (link.url.find("://") == std::string::npos) {
|
||||
// 简单的相对 URL 解析
|
||||
if (!link.url.empty() && link.url[0] == '/') {
|
||||
// 绝对路径
|
||||
size_t scheme_end = impl_->current_url_.find("://");
|
||||
if (scheme_end != std::string::npos) {
|
||||
size_t host_end = impl_->current_url_.find('/', scheme_end + 3);
|
||||
std::string base = (host_end != std::string::npos)
|
||||
? impl_->current_url_.substr(0, host_end)
|
||||
: impl_->current_url_;
|
||||
link.url = base + link.url;
|
||||
}
|
||||
} else if (link.url.find("://") == std::string::npos &&
|
||||
!link.url.empty() && link.url[0] != '#') {
|
||||
// 相对路径
|
||||
size_t last_slash = impl_->current_url_.rfind('/');
|
||||
if (last_slash != std::string::npos) {
|
||||
std::string base = impl_->current_url_.substr(0, last_slash + 1);
|
||||
link.url = base + link.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string BrowserEngine::getTitle() const {
|
||||
return impl_->title_;
|
||||
}
|
||||
|
||||
std::string BrowserEngine::getCurrentUrl() const {
|
||||
return impl_->current_url_;
|
||||
}
|
||||
|
||||
std::vector<LinkInfo> BrowserEngine::extractLinks() const {
|
||||
return impl_->links_;
|
||||
}
|
||||
|
||||
std::string BrowserEngine::getRenderedContent() const {
|
||||
return impl_->content_;
|
||||
}
|
||||
|
||||
bool BrowserEngine::goBack() {
|
||||
if (!canGoBack()) return false;
|
||||
impl_->history_index_--;
|
||||
return loadUrl(impl_->history_[impl_->history_index_]);
|
||||
}
|
||||
|
||||
bool BrowserEngine::goForward() {
|
||||
if (!canGoForward()) return false;
|
||||
impl_->history_index_++;
|
||||
return loadUrl(impl_->history_[impl_->history_index_]);
|
||||
}
|
||||
|
||||
bool BrowserEngine::refresh() {
|
||||
return loadUrl(impl_->current_url_);
|
||||
}
|
||||
|
||||
bool BrowserEngine::canGoBack() const {
|
||||
return impl_->history_index_ > 0;
|
||||
}
|
||||
|
||||
bool BrowserEngine::canGoForward() const {
|
||||
return impl_->history_index_ < impl_->history_.size() - 1;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
108
src/core/browser_engine.hpp
Normal file
108
src/core/browser_engine.hpp
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* @file browser_engine.hpp
|
||||
* @brief 浏览器引擎核心模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
#include "types.hpp"
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 浏览器引擎类
|
||||
*
|
||||
* 负责协调 HTTP 请求、HTML 解析和渲染
|
||||
*/
|
||||
class BrowserEngine {
|
||||
public:
|
||||
/**
|
||||
* @brief 默认构造函数
|
||||
*/
|
||||
BrowserEngine();
|
||||
|
||||
/**
|
||||
* @brief 析构函数
|
||||
*/
|
||||
~BrowserEngine();
|
||||
|
||||
/**
|
||||
* @brief 加载指定 URL
|
||||
* @param url 要加载的 URL
|
||||
* @return 加载成功返回 true
|
||||
*/
|
||||
bool loadUrl(const std::string& url);
|
||||
|
||||
/**
|
||||
* @brief 直接加载 HTML 内容
|
||||
* @param html HTML 字符串
|
||||
* @return 加载成功返回 true
|
||||
*/
|
||||
bool loadHtml(const std::string& html);
|
||||
|
||||
/**
|
||||
* @brief 获取页面标题
|
||||
* @return 页面标题,如果没有则返回空字符串
|
||||
*/
|
||||
std::string getTitle() const;
|
||||
|
||||
/**
|
||||
* @brief 获取当前 URL
|
||||
* @return 当前 URL
|
||||
*/
|
||||
std::string getCurrentUrl() const;
|
||||
|
||||
/**
|
||||
* @brief 提取页面中的链接
|
||||
* @return 链接列表
|
||||
*/
|
||||
std::vector<LinkInfo> extractLinks() const;
|
||||
|
||||
/**
|
||||
* @brief 获取渲染后的文本内容
|
||||
* @return 渲染后的文本
|
||||
*/
|
||||
std::string getRenderedContent() const;
|
||||
|
||||
/**
|
||||
* @brief 后退到上一页
|
||||
* @return 后退成功返回 true
|
||||
*/
|
||||
bool goBack();
|
||||
|
||||
/**
|
||||
* @brief 前进到下一页
|
||||
* @return 前进成功返回 true
|
||||
*/
|
||||
bool goForward();
|
||||
|
||||
/**
|
||||
* @brief 刷新当前页面
|
||||
* @return 刷新成功返回 true
|
||||
*/
|
||||
bool refresh();
|
||||
|
||||
/**
|
||||
* @brief 检查是否可以后退
|
||||
* @return 可以后退返回 true
|
||||
*/
|
||||
bool canGoBack() const;
|
||||
|
||||
/**
|
||||
* @brief 检查是否可以前进
|
||||
* @return 可以前进返回 true
|
||||
*/
|
||||
bool canGoForward() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
211
src/core/history_manager.cpp
Normal file
211
src/core/history_manager.cpp
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* @file history_manager.cpp
|
||||
* @brief History manager implementation
|
||||
*/
|
||||
|
||||
#include "core/history_manager.hpp"
|
||||
#include "utils/logger.hpp"
|
||||
#include "utils/config.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
namespace tut {
|
||||
|
||||
class HistoryManager::Impl {
|
||||
public:
|
||||
std::vector<HistoryEntry> entries_;
|
||||
std::string filepath_;
|
||||
static constexpr size_t MAX_ENTRIES = 1000;
|
||||
|
||||
Impl() {
|
||||
// Get history file path
|
||||
Config& config = Config::instance();
|
||||
std::string config_dir = config.getConfigPath();
|
||||
filepath_ = config_dir + "/history.json";
|
||||
|
||||
// Ensure config directory exists
|
||||
mkdir(config_dir.c_str(), 0755);
|
||||
|
||||
// Load existing history
|
||||
load();
|
||||
}
|
||||
|
||||
void load() {
|
||||
std::ifstream file(filepath_);
|
||||
if (!file.is_open()) {
|
||||
LOG_DEBUG << "No history file found, starting fresh";
|
||||
return;
|
||||
}
|
||||
|
||||
entries_.clear();
|
||||
std::string line;
|
||||
|
||||
// Skip opening brace
|
||||
std::getline(file, line);
|
||||
|
||||
while (std::getline(file, line)) {
|
||||
// Skip closing brace and empty lines
|
||||
if (line.find('}') != std::string::npos || line.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Simple JSON parsing for history entries
|
||||
// Format: {"title": "...", "url": "...", "timestamp": 123}
|
||||
if (line.find("\"title\"") != std::string::npos) {
|
||||
HistoryEntry entry;
|
||||
|
||||
// Parse title
|
||||
size_t title_start = line.find("\"title\"") + 10;
|
||||
size_t title_end = line.find("\"", title_start);
|
||||
if (title_start != std::string::npos && title_end != std::string::npos) {
|
||||
entry.title = line.substr(title_start, title_end - title_start);
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
size_t url_start = line.find("\"url\"") + 8;
|
||||
size_t url_end = line.find("\"", url_start);
|
||||
if (url_start != std::string::npos && url_end != std::string::npos) {
|
||||
entry.url = line.substr(url_start, url_end - url_start);
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
size_t ts_start = line.find("\"timestamp\"") + 13;
|
||||
size_t ts_end = line.find_first_of(",}", ts_start);
|
||||
if (ts_start != std::string::npos && ts_end != std::string::npos) {
|
||||
std::string ts_str = line.substr(ts_start, ts_end - ts_start);
|
||||
try {
|
||||
entry.timestamp = std::stoll(ts_str);
|
||||
} catch (...) {
|
||||
entry.timestamp = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.url.empty()) {
|
||||
entries_.push_back(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LOG_INFO << "Loaded " << entries_.size() << " history entries";
|
||||
}
|
||||
|
||||
void save() {
|
||||
std::ofstream file(filepath_);
|
||||
if (!file.is_open()) {
|
||||
LOG_ERROR << "Failed to save history to " << filepath_;
|
||||
return;
|
||||
}
|
||||
|
||||
file << "[\n";
|
||||
for (size_t i = 0; i < entries_.size(); ++i) {
|
||||
const auto& entry = entries_[i];
|
||||
file << " {\"title\": \"" << escapeJson(entry.title)
|
||||
<< "\", \"url\": \"" << escapeJson(entry.url)
|
||||
<< "\", \"timestamp\": " << entry.timestamp << "}";
|
||||
if (i < entries_.size() - 1) {
|
||||
file << ",";
|
||||
}
|
||||
file << "\n";
|
||||
}
|
||||
file << "]\n";
|
||||
|
||||
LOG_DEBUG << "Saved " << entries_.size() << " history entries";
|
||||
}
|
||||
|
||||
std::string escapeJson(const std::string& str) {
|
||||
std::string result;
|
||||
for (char c : str) {
|
||||
if (c == '"') {
|
||||
result += "\\\"";
|
||||
} else if (c == '\\') {
|
||||
result += "\\\\";
|
||||
} else if (c == '\n') {
|
||||
result += "\\n";
|
||||
} else if (c == '\t') {
|
||||
result += "\\t";
|
||||
} else {
|
||||
result += c;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int64_t getCurrentTimestamp() {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto duration = now.time_since_epoch();
|
||||
return std::chrono::duration_cast<std::chrono::seconds>(duration).count();
|
||||
}
|
||||
};
|
||||
|
||||
HistoryManager::HistoryManager() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
HistoryManager::~HistoryManager() = default;
|
||||
|
||||
void HistoryManager::recordVisit(const std::string& title, const std::string& url) {
|
||||
// Skip empty URLs or about:blank
|
||||
if (url.empty() || url == "about:blank") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if URL already exists
|
||||
auto it = std::find_if(impl_->entries_.begin(), impl_->entries_.end(),
|
||||
[&url](const HistoryEntry& e) { return e.url == url; });
|
||||
|
||||
if (it != impl_->entries_.end()) {
|
||||
// Update existing entry: update timestamp and move to front
|
||||
it->timestamp = impl_->getCurrentTimestamp();
|
||||
it->title = title; // Update title too in case it changed
|
||||
|
||||
// Move to front (most recent)
|
||||
HistoryEntry entry = *it;
|
||||
impl_->entries_.erase(it);
|
||||
impl_->entries_.insert(impl_->entries_.begin(), entry);
|
||||
|
||||
LOG_DEBUG << "Updated history: " << title << " (" << url << ")";
|
||||
} else {
|
||||
// Add new entry at front
|
||||
HistoryEntry entry(title, url, impl_->getCurrentTimestamp());
|
||||
impl_->entries_.insert(impl_->entries_.begin(), entry);
|
||||
|
||||
LOG_INFO << "Added to history: " << title << " (" << url << ")";
|
||||
|
||||
// Enforce max entries limit
|
||||
if (impl_->entries_.size() > Impl::MAX_ENTRIES) {
|
||||
impl_->entries_.resize(Impl::MAX_ENTRIES);
|
||||
LOG_DEBUG << "Trimmed history to " << Impl::MAX_ENTRIES << " entries";
|
||||
}
|
||||
}
|
||||
|
||||
impl_->save();
|
||||
}
|
||||
|
||||
std::vector<HistoryEntry> HistoryManager::getAll() const {
|
||||
return impl_->entries_; // Already sorted (newest first)
|
||||
}
|
||||
|
||||
std::vector<HistoryEntry> HistoryManager::getRecent(int count) const {
|
||||
if (count <= 0 || impl_->entries_.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
size_t n = std::min(static_cast<size_t>(count), impl_->entries_.size());
|
||||
return std::vector<HistoryEntry>(impl_->entries_.begin(),
|
||||
impl_->entries_.begin() + n);
|
||||
}
|
||||
|
||||
void HistoryManager::clear() {
|
||||
impl_->entries_.clear();
|
||||
impl_->save();
|
||||
LOG_INFO << "Cleared all history";
|
||||
}
|
||||
|
||||
size_t HistoryManager::size() const {
|
||||
return impl_->entries_.size();
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
78
src/core/history_manager.hpp
Normal file
78
src/core/history_manager.hpp
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* @file history_manager.hpp
|
||||
* @brief History manager for persistent browsing history
|
||||
* @author m1ngsama
|
||||
* @date 2025-01-01
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief History entry
|
||||
*/
|
||||
struct HistoryEntry {
|
||||
std::string title;
|
||||
std::string url;
|
||||
int64_t timestamp{0}; // Unix timestamp of last visit
|
||||
|
||||
HistoryEntry() = default;
|
||||
HistoryEntry(const std::string& t, const std::string& u, int64_t ts = 0)
|
||||
: title(t), url(u), timestamp(ts) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief History manager with JSON persistence
|
||||
*
|
||||
* Manages browsing history with automatic persistence to
|
||||
* ~/.config/tut/history.json
|
||||
*
|
||||
* Features:
|
||||
* - Auto-records page visits
|
||||
* - Updates timestamp on revisit (moves to front)
|
||||
* - Limits to max 1000 entries
|
||||
*/
|
||||
class HistoryManager {
|
||||
public:
|
||||
HistoryManager();
|
||||
~HistoryManager();
|
||||
|
||||
/**
|
||||
* @brief Record a page visit
|
||||
* If URL exists, updates timestamp and moves to front
|
||||
* @param title Page title
|
||||
* @param url Page URL
|
||||
*/
|
||||
void recordVisit(const std::string& title, const std::string& url);
|
||||
|
||||
/**
|
||||
* @brief Get all history entries (sorted by timestamp, newest first)
|
||||
*/
|
||||
std::vector<HistoryEntry> getAll() const;
|
||||
|
||||
/**
|
||||
* @brief Get recent history (last N entries)
|
||||
*/
|
||||
std::vector<HistoryEntry> getRecent(int count) const;
|
||||
|
||||
/**
|
||||
* @brief Clear all history
|
||||
*/
|
||||
void clear();
|
||||
|
||||
/**
|
||||
* @brief Get total number of history entries
|
||||
*/
|
||||
size_t size() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
182
src/core/http_client.cpp
Normal file
182
src/core/http_client.cpp
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* @file http_client.cpp
|
||||
* @brief HTTP 客户端实现
|
||||
*/
|
||||
|
||||
#include "core/http_client.hpp"
|
||||
|
||||
// cpp-httplib HTTPS 支持已通过 CMake 启用
|
||||
#include <httplib.h>
|
||||
|
||||
#include <chrono>
|
||||
|
||||
namespace tut {
|
||||
|
||||
class HttpClient::Impl {
|
||||
public:
|
||||
HttpConfig config;
|
||||
std::map<std::string, std::map<std::string, std::string>> cookies;
|
||||
|
||||
HttpResponse makeRequest(const std::string& url,
|
||||
const std::string& method,
|
||||
const std::string& body = "",
|
||||
const std::string& content_type = "",
|
||||
const std::map<std::string, std::string>& extra_headers = {}) {
|
||||
HttpResponse response;
|
||||
|
||||
auto start_time = std::chrono::steady_clock::now();
|
||||
|
||||
try {
|
||||
// 解析 URL
|
||||
size_t scheme_end = url.find("://");
|
||||
if (scheme_end == std::string::npos) {
|
||||
response.error = "Invalid URL: missing scheme";
|
||||
return response;
|
||||
}
|
||||
|
||||
std::string scheme = url.substr(0, scheme_end);
|
||||
std::string rest = url.substr(scheme_end + 3);
|
||||
|
||||
size_t path_start = rest.find('/');
|
||||
std::string host_port = (path_start != std::string::npos)
|
||||
? rest.substr(0, path_start)
|
||||
: rest;
|
||||
std::string path = (path_start != std::string::npos)
|
||||
? rest.substr(path_start)
|
||||
: "/";
|
||||
|
||||
// 创建客户端
|
||||
std::unique_ptr<httplib::Client> client;
|
||||
if (scheme == "https") {
|
||||
client = std::make_unique<httplib::Client>("https://" + host_port);
|
||||
} else {
|
||||
client = std::make_unique<httplib::Client>("http://" + host_port);
|
||||
}
|
||||
|
||||
// 配置客户端
|
||||
client->set_connection_timeout(config.timeout_seconds);
|
||||
client->set_read_timeout(config.timeout_seconds);
|
||||
client->set_follow_location(config.follow_redirects);
|
||||
|
||||
// 设置请求头
|
||||
httplib::Headers headers;
|
||||
headers.emplace("User-Agent", config.user_agent);
|
||||
for (const auto& [key, value] : extra_headers) {
|
||||
headers.emplace(key, value);
|
||||
}
|
||||
|
||||
// 添加 Cookie
|
||||
std::string cookie_str;
|
||||
// 提取主机名用于 cookie 查找
|
||||
std::string host = host_port;
|
||||
size_t colon_pos = host.find(':');
|
||||
if (colon_pos != std::string::npos) {
|
||||
host = host.substr(0, colon_pos);
|
||||
}
|
||||
|
||||
auto domain_cookies = cookies.find(host);
|
||||
if (domain_cookies != cookies.end()) {
|
||||
for (const auto& [name, value] : domain_cookies->second) {
|
||||
if (!cookie_str.empty()) cookie_str += "; ";
|
||||
cookie_str += name + "=" + value;
|
||||
}
|
||||
if (!cookie_str.empty()) {
|
||||
headers.emplace("Cookie", cookie_str);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
httplib::Result result;
|
||||
if (method == "GET") {
|
||||
result = client->Get(path, headers);
|
||||
} else if (method == "POST") {
|
||||
result = client->Post(path, headers, body, content_type);
|
||||
} else if (method == "HEAD") {
|
||||
result = client->Head(path, headers);
|
||||
}
|
||||
|
||||
auto end_time = std::chrono::steady_clock::now();
|
||||
response.elapsed_time = std::chrono::duration<double>(end_time - start_time).count();
|
||||
|
||||
if (result) {
|
||||
response.status_code = result->status;
|
||||
response.body = result->body;
|
||||
|
||||
for (const auto& [key, value] : result->headers) {
|
||||
response.headers[key] = value;
|
||||
}
|
||||
} else {
|
||||
response.error = "Request failed: " + httplib::to_string(result.error());
|
||||
}
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
response.error = std::string("Exception: ") + e.what();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
HttpClient::HttpClient(const HttpConfig& config)
|
||||
: impl_(std::make_unique<Impl>()) {
|
||||
impl_->config = config;
|
||||
}
|
||||
|
||||
HttpClient::~HttpClient() = default;
|
||||
|
||||
HttpResponse HttpClient::get(const std::string& url,
|
||||
const std::map<std::string, std::string>& headers) {
|
||||
return impl_->makeRequest(url, "GET", "", "", headers);
|
||||
}
|
||||
|
||||
HttpResponse HttpClient::post(const std::string& url,
|
||||
const std::string& body,
|
||||
const std::string& content_type,
|
||||
const std::map<std::string, std::string>& headers) {
|
||||
return impl_->makeRequest(url, "POST", body, content_type, headers);
|
||||
}
|
||||
|
||||
HttpResponse HttpClient::head(const std::string& url) {
|
||||
return impl_->makeRequest(url, "HEAD");
|
||||
}
|
||||
|
||||
bool HttpClient::download(const std::string& url,
|
||||
const std::string& filepath,
|
||||
ProgressCallback progress) {
|
||||
// TODO: 实现文件下载
|
||||
(void)url;
|
||||
(void)filepath;
|
||||
(void)progress;
|
||||
return false;
|
||||
}
|
||||
|
||||
void HttpClient::setConfig(const HttpConfig& config) {
|
||||
impl_->config = config;
|
||||
}
|
||||
|
||||
const HttpConfig& HttpClient::getConfig() const {
|
||||
return impl_->config;
|
||||
}
|
||||
|
||||
void HttpClient::setCookie(const std::string& domain,
|
||||
const std::string& name,
|
||||
const std::string& value) {
|
||||
impl_->cookies[domain][name] = value;
|
||||
}
|
||||
|
||||
std::optional<std::string> HttpClient::getCookie(const std::string& domain,
|
||||
const std::string& name) const {
|
||||
auto domain_it = impl_->cookies.find(domain);
|
||||
if (domain_it == impl_->cookies.end()) return std::nullopt;
|
||||
|
||||
auto cookie_it = domain_it->second.find(name);
|
||||
if (cookie_it == domain_it->second.end()) return std::nullopt;
|
||||
|
||||
return cookie_it->second;
|
||||
}
|
||||
|
||||
void HttpClient::clearCookies() {
|
||||
impl_->cookies.clear();
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
160
src/core/http_client.hpp
Normal file
160
src/core/http_client.hpp
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* @file http_client.hpp
|
||||
* @brief HTTP 客户端模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief HTTP 响应结构体
|
||||
*/
|
||||
struct HttpResponse {
|
||||
int status_code{0}; ///< HTTP 状态码
|
||||
std::string status_text; ///< 状态文本
|
||||
std::map<std::string, std::string> headers; ///< 响应头
|
||||
std::string body; ///< 响应体
|
||||
std::string error; ///< 错误信息
|
||||
double elapsed_time{0.0}; ///< 请求耗时(秒)
|
||||
|
||||
bool isSuccess() const { return status_code >= 200 && status_code < 300; }
|
||||
bool isRedirect() const { return status_code >= 300 && status_code < 400; }
|
||||
bool isError() const { return status_code >= 400 || !error.empty(); }
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief HTTP 请求配置
|
||||
*/
|
||||
struct HttpConfig {
|
||||
int timeout_seconds{30}; ///< 超时时间(秒)
|
||||
int max_redirects{5}; ///< 最大重定向次数
|
||||
bool follow_redirects{true}; ///< 是否自动跟随重定向
|
||||
bool verify_ssl{true}; ///< 是否验证 SSL 证书
|
||||
std::string user_agent{"TUT/0.1"}; ///< User-Agent 字符串
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 下载进度回调函数类型
|
||||
* @param downloaded 已下载字节数
|
||||
* @param total 总字节数 (如果未知则为 0)
|
||||
*/
|
||||
using ProgressCallback = std::function<void(size_t downloaded, size_t total)>;
|
||||
|
||||
/**
|
||||
* @brief HTTP 客户端类
|
||||
*
|
||||
* 负责发送 HTTP/HTTPS 请求
|
||||
*
|
||||
* @example
|
||||
* @code
|
||||
* HttpClient client;
|
||||
* auto response = client.get("https://example.com");
|
||||
* if (response.isSuccess()) {
|
||||
* std::cout << response.body << std::endl;
|
||||
* }
|
||||
* @endcode
|
||||
*/
|
||||
class HttpClient {
|
||||
public:
|
||||
/**
|
||||
* @brief 构造函数
|
||||
* @param config HTTP 配置
|
||||
*/
|
||||
explicit HttpClient(const HttpConfig& config = HttpConfig{});
|
||||
|
||||
/**
|
||||
* @brief 析构函数
|
||||
*/
|
||||
~HttpClient();
|
||||
|
||||
/**
|
||||
* @brief 发送 GET 请求
|
||||
* @param url 请求 URL
|
||||
* @param headers 额外的请求头
|
||||
* @return HTTP 响应
|
||||
*/
|
||||
HttpResponse get(const std::string& url,
|
||||
const std::map<std::string, std::string>& headers = {});
|
||||
|
||||
/**
|
||||
* @brief 发送 POST 请求
|
||||
* @param url 请求 URL
|
||||
* @param body 请求体
|
||||
* @param content_type Content-Type
|
||||
* @param headers 额外的请求头
|
||||
* @return HTTP 响应
|
||||
*/
|
||||
HttpResponse post(const std::string& url,
|
||||
const std::string& body,
|
||||
const std::string& content_type = "application/x-www-form-urlencoded",
|
||||
const std::map<std::string, std::string>& headers = {});
|
||||
|
||||
/**
|
||||
* @brief 发送 HEAD 请求
|
||||
* @param url 请求 URL
|
||||
* @return HTTP 响应
|
||||
*/
|
||||
HttpResponse head(const std::string& url);
|
||||
|
||||
/**
|
||||
* @brief 下载文件
|
||||
* @param url 文件 URL
|
||||
* @param filepath 保存路径
|
||||
* @param progress 进度回调
|
||||
* @return 下载成功返回 true
|
||||
*/
|
||||
bool download(const std::string& url,
|
||||
const std::string& filepath,
|
||||
ProgressCallback progress = nullptr);
|
||||
|
||||
/**
|
||||
* @brief 设置配置
|
||||
* @param config 新配置
|
||||
*/
|
||||
void setConfig(const HttpConfig& config);
|
||||
|
||||
/**
|
||||
* @brief 获取当前配置
|
||||
* @return 当前配置
|
||||
*/
|
||||
const HttpConfig& getConfig() const;
|
||||
|
||||
/**
|
||||
* @brief 设置 Cookie
|
||||
* @param domain 域名
|
||||
* @param name Cookie 名
|
||||
* @param value Cookie 值
|
||||
*/
|
||||
void setCookie(const std::string& domain,
|
||||
const std::string& name,
|
||||
const std::string& value);
|
||||
|
||||
/**
|
||||
* @brief 获取 Cookie
|
||||
* @param domain 域名
|
||||
* @param name Cookie 名
|
||||
* @return Cookie 值
|
||||
*/
|
||||
std::optional<std::string> getCookie(const std::string& domain,
|
||||
const std::string& name) const;
|
||||
|
||||
/**
|
||||
* @brief 清除所有 Cookie
|
||||
*/
|
||||
void clearCookies();
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
23
src/core/types.hpp
Normal file
23
src/core/types.hpp
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* @file types.hpp
|
||||
* @brief Common types used across TUT modules
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-31
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 链接信息结构体
|
||||
*/
|
||||
struct LinkInfo {
|
||||
std::string url; ///< 链接 URL
|
||||
std::string text; ///< 链接文本
|
||||
int line{0}; ///< 所在行号
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
236
src/core/url_parser.cpp
Normal file
236
src/core/url_parser.cpp
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* @file url_parser.cpp
|
||||
* @brief URL 解析器实现
|
||||
*/
|
||||
|
||||
#include "core/url_parser.hpp"
|
||||
#include <regex>
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
|
||||
namespace tut {
|
||||
|
||||
std::optional<UrlResult> UrlParser::parse(const std::string& url) const {
|
||||
if (url.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// URL 正则表达式
|
||||
// 格式: scheme://[userinfo@]host[:port][/path][?query][#fragment]
|
||||
static const std::regex url_regex(
|
||||
R"(^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?)",
|
||||
std::regex::ECMAScript
|
||||
);
|
||||
|
||||
std::smatch matches;
|
||||
if (!std::regex_match(url, matches, url_regex)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
UrlResult result;
|
||||
|
||||
// 解析 scheme
|
||||
if (matches[2].matched) {
|
||||
result.scheme = matches[2].str();
|
||||
std::transform(result.scheme.begin(), result.scheme.end(),
|
||||
result.scheme.begin(), ::tolower);
|
||||
if (!validateScheme(result.scheme)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 authority (userinfo@host:port)
|
||||
if (matches[4].matched) {
|
||||
std::string authority = matches[4].str();
|
||||
|
||||
// 提取 userinfo
|
||||
size_t at_pos = authority.find('@');
|
||||
if (at_pos != std::string::npos) {
|
||||
result.userinfo = authority.substr(0, at_pos);
|
||||
authority = authority.substr(at_pos + 1);
|
||||
}
|
||||
|
||||
// 提取端口号
|
||||
size_t colon_pos = authority.rfind(':');
|
||||
if (colon_pos != std::string::npos) {
|
||||
std::string port_str = authority.substr(colon_pos + 1);
|
||||
try {
|
||||
result.port = static_cast<uint16_t>(std::stoi(port_str));
|
||||
} catch (...) {
|
||||
result.port = getDefaultPort(result.scheme);
|
||||
}
|
||||
result.host = authority.substr(0, colon_pos);
|
||||
} else {
|
||||
result.host = authority;
|
||||
result.port = getDefaultPort(result.scheme);
|
||||
}
|
||||
|
||||
if (!validateHost(result.host)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 path
|
||||
if (matches[5].matched) {
|
||||
result.path = matches[5].str();
|
||||
if (result.path.empty()) {
|
||||
result.path = "/";
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 query
|
||||
if (matches[7].matched) {
|
||||
result.query = matches[7].str();
|
||||
}
|
||||
|
||||
// 解析 fragment
|
||||
if (matches[9].matched) {
|
||||
result.fragment = matches[9].str();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string UrlParser::resolveRelative(const std::string& base,
|
||||
const std::string& relative) const {
|
||||
if (relative.empty()) {
|
||||
return base;
|
||||
}
|
||||
|
||||
// 如果是绝对 URL,直接返回
|
||||
if (relative.find("://") != std::string::npos) {
|
||||
return relative;
|
||||
}
|
||||
|
||||
auto base_result = parse(base);
|
||||
if (!base_result) {
|
||||
return relative;
|
||||
}
|
||||
|
||||
// 协议相对 URL (//example.com/path)
|
||||
if (relative.substr(0, 2) == "//") {
|
||||
return base_result->scheme + ":" + relative;
|
||||
}
|
||||
|
||||
std::string result = base_result->scheme + "://" + base_result->host;
|
||||
if (base_result->port != getDefaultPort(base_result->scheme)) {
|
||||
result += ":" + std::to_string(base_result->port);
|
||||
}
|
||||
|
||||
// 绝对路径
|
||||
if (relative[0] == '/') {
|
||||
result += relative;
|
||||
} else {
|
||||
// 相对路径
|
||||
std::string base_path = base_result->path;
|
||||
size_t last_slash = base_path.rfind('/');
|
||||
if (last_slash != std::string::npos) {
|
||||
base_path = base_path.substr(0, last_slash + 1);
|
||||
}
|
||||
result += base_path + relative;
|
||||
}
|
||||
|
||||
return normalize(result);
|
||||
}
|
||||
|
||||
std::string UrlParser::normalize(const std::string& url) const {
|
||||
auto result = parse(url);
|
||||
if (!result) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 移除路径中的 . 和 ..
|
||||
std::vector<std::string> segments;
|
||||
std::istringstream iss(result->path);
|
||||
std::string segment;
|
||||
|
||||
while (std::getline(iss, segment, '/')) {
|
||||
if (segment == "..") {
|
||||
if (!segments.empty()) {
|
||||
segments.pop_back();
|
||||
}
|
||||
} else if (segment != "." && !segment.empty()) {
|
||||
segments.push_back(segment);
|
||||
}
|
||||
}
|
||||
|
||||
std::string normalized_path = "/";
|
||||
for (size_t i = 0; i < segments.size(); ++i) {
|
||||
normalized_path += segments[i];
|
||||
if (i < segments.size() - 1) {
|
||||
normalized_path += "/";
|
||||
}
|
||||
}
|
||||
|
||||
// 重建 URL
|
||||
std::string normalized = result->scheme + "://" + result->host;
|
||||
if (result->port != getDefaultPort(result->scheme)) {
|
||||
normalized += ":" + std::to_string(result->port);
|
||||
}
|
||||
normalized += normalized_path;
|
||||
|
||||
if (!result->query.empty()) {
|
||||
normalized += "?" + result->query;
|
||||
}
|
||||
if (!result->fragment.empty()) {
|
||||
normalized += "#" + result->fragment;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
std::string UrlParser::encode(const std::string& str) {
|
||||
std::ostringstream encoded;
|
||||
encoded << std::hex << std::uppercase;
|
||||
|
||||
for (unsigned char c : str) {
|
||||
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
|
||||
encoded << c;
|
||||
} else {
|
||||
encoded << '%' << std::setw(2) << std::setfill('0') << static_cast<int>(c);
|
||||
}
|
||||
}
|
||||
|
||||
return encoded.str();
|
||||
}
|
||||
|
||||
std::string UrlParser::decode(const std::string& str) {
|
||||
std::string decoded;
|
||||
decoded.reserve(str.size());
|
||||
|
||||
for (size_t i = 0; i < str.size(); ++i) {
|
||||
if (str[i] == '%' && i + 2 < str.size()) {
|
||||
int value;
|
||||
std::istringstream iss(str.substr(i + 1, 2));
|
||||
if (iss >> std::hex >> value) {
|
||||
decoded += static_cast<char>(value);
|
||||
i += 2;
|
||||
} else {
|
||||
decoded += str[i];
|
||||
}
|
||||
} else if (str[i] == '+') {
|
||||
decoded += ' ';
|
||||
} else {
|
||||
decoded += str[i];
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
bool UrlParser::validateScheme(const std::string& scheme) const {
|
||||
return scheme == "http" || scheme == "https" || scheme == "file";
|
||||
}
|
||||
|
||||
bool UrlParser::validateHost(const std::string& host) const {
|
||||
return !host.empty();
|
||||
}
|
||||
|
||||
uint16_t UrlParser::getDefaultPort(const std::string& scheme) const {
|
||||
if (scheme == "https") return 443;
|
||||
if (scheme == "http") return 80;
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
103
src/core/url_parser.hpp
Normal file
103
src/core/url_parser.hpp
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* @file url_parser.hpp
|
||||
* @brief URL 解析器模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <cstdint>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief URL 解析结果结构体
|
||||
*
|
||||
* 包含解析后的 URL 各个组成部分
|
||||
*/
|
||||
struct UrlResult {
|
||||
std::string scheme; ///< 协议 (http, https, file)
|
||||
std::string host; ///< 主机名 (example.com)
|
||||
uint16_t port{80}; ///< 端口号
|
||||
std::string path; ///< 路径 (/path/to/resource)
|
||||
std::string query; ///< 查询参数 (key=value&foo=bar)
|
||||
std::string fragment; ///< 片段标识符 (section)
|
||||
std::string userinfo; ///< 用户信息 (user:pass)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief URL 解析器类
|
||||
*
|
||||
* 负责将 URL 字符串解析为结构化对象
|
||||
*
|
||||
* @example
|
||||
* @code
|
||||
* UrlParser parser;
|
||||
* auto result = parser.parse("https://example.com:8080/path?query=1");
|
||||
* if (result) {
|
||||
* std::cout << "Host: " << result->host << std::endl;
|
||||
* }
|
||||
* @endcode
|
||||
*/
|
||||
class UrlParser {
|
||||
public:
|
||||
/**
|
||||
* @brief 默认构造函数
|
||||
*/
|
||||
UrlParser() = default;
|
||||
|
||||
/**
|
||||
* @brief 解析 URL 字符串
|
||||
*
|
||||
* @param url 完整的 URL 字符串
|
||||
* @return 解析成功返回 UrlResult,失败返回 std::nullopt
|
||||
*
|
||||
* @note 支持 http, https, file 协议
|
||||
* @note 会自动处理端口号默认值
|
||||
*/
|
||||
std::optional<UrlResult> parse(const std::string& url) const;
|
||||
|
||||
/**
|
||||
* @brief 解析相对 URL
|
||||
*
|
||||
* @param base 基础 URL
|
||||
* @param relative 相对 URL
|
||||
* @return 解析成功返回完整 URL,失败返回空字符串
|
||||
*/
|
||||
std::string resolveRelative(const std::string& base,
|
||||
const std::string& relative) const;
|
||||
|
||||
/**
|
||||
* @brief 规范化 URL
|
||||
*
|
||||
* @param url 待规范化的 URL
|
||||
* @return 规范化后的 URL
|
||||
*/
|
||||
std::string normalize(const std::string& url) const;
|
||||
|
||||
/**
|
||||
* @brief URL 编码
|
||||
*
|
||||
* @param str 待编码的字符串
|
||||
* @return 编码后的字符串
|
||||
*/
|
||||
static std::string encode(const std::string& str);
|
||||
|
||||
/**
|
||||
* @brief URL 解码
|
||||
*
|
||||
* @param str 待解码的字符串
|
||||
* @return 解码后的字符串
|
||||
*/
|
||||
static std::string decode(const std::string& str);
|
||||
|
||||
private:
|
||||
bool validateScheme(const std::string& scheme) const;
|
||||
bool validateHost(const std::string& host) const;
|
||||
uint16_t getDefaultPort(const std::string& scheme) const;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
666
src/dom_tree.cpp
666
src/dom_tree.cpp
|
|
@ -1,666 +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, tree.images, base_url);
|
||||
|
||||
// 3. 提取标题
|
||||
if (tree.root) {
|
||||
tree.title = extract_title(tree.root.get());
|
||||
}
|
||||
|
||||
// 4. 清理gumbo资源
|
||||
gumbo_destroy_output(&kGumboDefaultOptions, output);
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
std::unique_ptr<DomNode> DomTreeBuilder::convert_node(
|
||||
GumboNode* gumbo_node,
|
||||
std::vector<Link>& links,
|
||||
std::vector<DomNode*>& form_fields,
|
||||
std::vector<DomNode*>& images,
|
||||
const std::string& base_url
|
||||
) {
|
||||
if (!gumbo_node) return nullptr;
|
||||
|
||||
auto node = std::make_unique<DomNode>();
|
||||
|
||||
if (gumbo_node->type == GUMBO_NODE_ELEMENT) {
|
||||
node->node_type = NodeType::ELEMENT;
|
||||
GumboElement& element = gumbo_node->v.element;
|
||||
|
||||
// 设置标签名
|
||||
node->tag_name = gumbo_normalized_tagname(element.tag);
|
||||
node->element_type = map_gumbo_tag_to_element_type(element.tag);
|
||||
|
||||
// Assign current form ID to children
|
||||
node->form_id = g_current_form_id;
|
||||
|
||||
// Special handling for FORM tag
|
||||
if (element.tag == GUMBO_TAG_FORM) {
|
||||
node->form_id = g_next_form_id++;
|
||||
g_current_form_id = node->form_id;
|
||||
|
||||
GumboAttribute* action_attr = gumbo_get_attribute(&element.attributes, "action");
|
||||
if (action_attr) node->action = resolve_url(action_attr->value, base_url);
|
||||
else node->action = base_url; // Default to current URL
|
||||
|
||||
GumboAttribute* method_attr = gumbo_get_attribute(&element.attributes, "method");
|
||||
if (method_attr) node->method = method_attr->value;
|
||||
else node->method = "GET";
|
||||
|
||||
// Transform to uppercase
|
||||
std::transform(node->method.begin(), node->method.end(), node->method.begin(), ::toupper);
|
||||
}
|
||||
|
||||
// Handle INPUT
|
||||
if (element.tag == GUMBO_TAG_INPUT) {
|
||||
GumboAttribute* type_attr = gumbo_get_attribute(&element.attributes, "type");
|
||||
node->input_type = type_attr ? type_attr->value : "text";
|
||||
|
||||
GumboAttribute* name_attr = gumbo_get_attribute(&element.attributes, "name");
|
||||
if (name_attr) node->name = name_attr->value;
|
||||
|
||||
GumboAttribute* value_attr = gumbo_get_attribute(&element.attributes, "value");
|
||||
if (value_attr) node->value = value_attr->value;
|
||||
|
||||
GumboAttribute* placeholder_attr = gumbo_get_attribute(&element.attributes, "placeholder");
|
||||
if (placeholder_attr) node->placeholder = placeholder_attr->value;
|
||||
|
||||
if (gumbo_get_attribute(&element.attributes, "checked")) {
|
||||
node->checked = true;
|
||||
}
|
||||
|
||||
// Register form field
|
||||
if (node->input_type != "hidden") {
|
||||
node->field_index = form_fields.size();
|
||||
form_fields.push_back(node.get());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle TEXTAREA
|
||||
if (element.tag == GUMBO_TAG_TEXTAREA) {
|
||||
node->input_type = "textarea";
|
||||
GumboAttribute* name_attr = gumbo_get_attribute(&element.attributes, "name");
|
||||
if (name_attr) node->name = name_attr->value;
|
||||
|
||||
GumboAttribute* placeholder_attr = gumbo_get_attribute(&element.attributes, "placeholder");
|
||||
if (placeholder_attr) node->placeholder = placeholder_attr->value;
|
||||
|
||||
// Register form field
|
||||
node->field_index = form_fields.size();
|
||||
form_fields.push_back(node.get());
|
||||
}
|
||||
|
||||
// Handle SELECT
|
||||
if (element.tag == GUMBO_TAG_SELECT) {
|
||||
node->input_type = "select";
|
||||
GumboAttribute* name_attr = gumbo_get_attribute(&element.attributes, "name");
|
||||
if (name_attr) node->name = name_attr->value;
|
||||
|
||||
// Register form field
|
||||
node->field_index = form_fields.size();
|
||||
form_fields.push_back(node.get());
|
||||
}
|
||||
|
||||
// Handle OPTION
|
||||
if (element.tag == GUMBO_TAG_OPTION) {
|
||||
node->input_type = "option";
|
||||
GumboAttribute* value_attr = gumbo_get_attribute(&element.attributes, "value");
|
||||
if (value_attr) node->value = value_attr->value;
|
||||
if (gumbo_get_attribute(&element.attributes, "selected")) {
|
||||
node->checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle BUTTON
|
||||
if (element.tag == GUMBO_TAG_BUTTON) {
|
||||
GumboAttribute* type_attr = gumbo_get_attribute(&element.attributes, "type");
|
||||
node->input_type = type_attr ? type_attr->value : "submit";
|
||||
|
||||
GumboAttribute* name_attr = gumbo_get_attribute(&element.attributes, "name");
|
||||
if (name_attr) node->name = name_attr->value;
|
||||
|
||||
GumboAttribute* value_attr = gumbo_get_attribute(&element.attributes, "value");
|
||||
if (value_attr) node->value = value_attr->value;
|
||||
|
||||
// Register form field
|
||||
node->field_index = form_fields.size();
|
||||
form_fields.push_back(node.get());
|
||||
}
|
||||
|
||||
// Handle IMG
|
||||
if (element.tag == GUMBO_TAG_IMG) {
|
||||
GumboAttribute* src_attr = gumbo_get_attribute(&element.attributes, "src");
|
||||
if (src_attr && src_attr->value) {
|
||||
node->img_src = resolve_url(src_attr->value, base_url);
|
||||
}
|
||||
|
||||
GumboAttribute* alt_attr = gumbo_get_attribute(&element.attributes, "alt");
|
||||
if (alt_attr) node->alt_text = alt_attr->value;
|
||||
|
||||
GumboAttribute* width_attr = gumbo_get_attribute(&element.attributes, "width");
|
||||
if (width_attr && width_attr->value) {
|
||||
try { node->img_width = std::stoi(width_attr->value); } catch (...) {}
|
||||
}
|
||||
|
||||
GumboAttribute* height_attr = gumbo_get_attribute(&element.attributes, "height");
|
||||
if (height_attr && height_attr->value) {
|
||||
try { node->img_height = std::stoi(height_attr->value); } catch (...) {}
|
||||
}
|
||||
|
||||
// 添加到图片列表(用于后续下载)
|
||||
if (!node->img_src.empty()) {
|
||||
images.push_back(node.get());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 处理<a>标签
|
||||
if (element.tag == GUMBO_TAG_A) {
|
||||
GumboAttribute* href_attr = gumbo_get_attribute(&element.attributes, "href");
|
||||
if (href_attr && href_attr->value) {
|
||||
std::string href = href_attr->value;
|
||||
// 过滤锚点链接和javascript链接
|
||||
if (!href.empty() && href[0] != '#' &&
|
||||
href.find("javascript:") != 0 &&
|
||||
href.find("mailto:") != 0) {
|
||||
|
||||
node->href = resolve_url(href, base_url);
|
||||
|
||||
// 注册到全局链接列表
|
||||
Link link;
|
||||
link.text = extract_text_from_gumbo(gumbo_node);
|
||||
link.url = node->href;
|
||||
link.position = links.size();
|
||||
|
||||
links.push_back(link);
|
||||
node->link_index = links.size() - 1;
|
||||
node->element_type = ElementType::LINK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理表格单元格属性
|
||||
if (element.tag == GUMBO_TAG_TH) {
|
||||
node->is_table_header = true;
|
||||
}
|
||||
|
||||
if (element.tag == GUMBO_TAG_TD || element.tag == GUMBO_TAG_TH) {
|
||||
GumboAttribute* colspan_attr = gumbo_get_attribute(&element.attributes, "colspan");
|
||||
if (colspan_attr && colspan_attr->value) {
|
||||
node->colspan = std::stoi(colspan_attr->value);
|
||||
}
|
||||
|
||||
GumboAttribute* rowspan_attr = gumbo_get_attribute(&element.attributes, "rowspan");
|
||||
if (rowspan_attr && rowspan_attr->value) {
|
||||
node->rowspan = std::stoi(rowspan_attr->value);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
GumboVector* children = &element.children;
|
||||
for (unsigned int i = 0; i < children->length; ++i) {
|
||||
auto child = convert_node(
|
||||
static_cast<GumboNode*>(children->data[i]),
|
||||
links,
|
||||
form_fields,
|
||||
images,
|
||||
base_url
|
||||
);
|
||||
if (child) {
|
||||
child->parent = node.get();
|
||||
node->children.push_back(std::move(child));
|
||||
|
||||
// For TEXTAREA, content is value
|
||||
if (element.tag == GUMBO_TAG_TEXTAREA && child->node_type == NodeType::TEXT) {
|
||||
node->value += child->text_content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form ID if we are exiting a form
|
||||
if (element.tag == GUMBO_TAG_FORM) {
|
||||
g_current_form_id = -1; // Assuming no nested forms
|
||||
}
|
||||
}
|
||||
else if (gumbo_node->type == GUMBO_NODE_TEXT) {
|
||||
node->node_type = NodeType::TEXT;
|
||||
std::string text = gumbo_node->v.text.text;
|
||||
|
||||
// 解码HTML实体
|
||||
node->text_content = decode_html_entities(text);
|
||||
node->form_id = g_current_form_id;
|
||||
}
|
||||
else if (gumbo_node->type == GUMBO_NODE_DOCUMENT) {
|
||||
node->node_type = NodeType::DOCUMENT;
|
||||
node->tag_name = "document";
|
||||
|
||||
// 处理文档节点的子节点
|
||||
GumboDocument& doc = gumbo_node->v.document;
|
||||
for (unsigned int i = 0; i < doc.children.length; ++i) {
|
||||
auto child = convert_node(
|
||||
static_cast<GumboNode*>(doc.children.data[i]),
|
||||
links,
|
||||
form_fields,
|
||||
images,
|
||||
base_url
|
||||
);
|
||||
if (child) {
|
||||
child->parent = node.get();
|
||||
node->children.push_back(std::move(child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
std::string DomTreeBuilder::extract_title(DomNode* root) {
|
||||
if (!root) return "";
|
||||
|
||||
// 递归查找<title>标签
|
||||
std::function<std::string(DomNode*)> find_title = [&](DomNode* node) -> std::string {
|
||||
if (!node) return "";
|
||||
|
||||
if (node->tag_name == "title") {
|
||||
return node->get_all_text();
|
||||
}
|
||||
|
||||
for (auto& child : node->children) {
|
||||
std::string title = find_title(child.get());
|
||||
if (!title.empty()) return title;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
std::string title = find_title(root);
|
||||
|
||||
// 如果没有<title>,尝试找第一个<h1>
|
||||
if (title.empty()) {
|
||||
std::function<std::string(DomNode*)> find_h1 = [&](DomNode* node) -> std::string {
|
||||
if (!node) return "";
|
||||
|
||||
if (node->tag_name == "h1") {
|
||||
return node->get_all_text();
|
||||
}
|
||||
|
||||
for (auto& child : node->children) {
|
||||
std::string h1 = find_h1(child.get());
|
||||
if (!h1.empty()) return h1;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
title = find_h1(root);
|
||||
}
|
||||
|
||||
// 清理标题中的多余空白
|
||||
title = std::regex_replace(title, std::regex(R"(\s+)"), " ");
|
||||
|
||||
// 去除首尾空白
|
||||
size_t start = title.find_first_not_of(" \t\n\r");
|
||||
if (start == std::string::npos) return "";
|
||||
|
||||
size_t end = title.find_last_not_of(" \t\n\r");
|
||||
return title.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
std::string DomTreeBuilder::extract_text_from_gumbo(GumboNode* node) {
|
||||
if (!node) return "";
|
||||
|
||||
std::string text;
|
||||
|
||||
if (node->type == GUMBO_NODE_TEXT) {
|
||||
text = node->v.text.text;
|
||||
} else if (node->type == GUMBO_NODE_ELEMENT) {
|
||||
GumboVector* children = &node->v.element.children;
|
||||
for (unsigned int i = 0; i < children->length; ++i) {
|
||||
text += extract_text_from_gumbo(static_cast<GumboNode*>(children->data[i]));
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
ElementType DomTreeBuilder::map_gumbo_tag_to_element_type(int gumbo_tag) {
|
||||
switch (gumbo_tag) {
|
||||
case GUMBO_TAG_H1: return ElementType::HEADING1;
|
||||
case GUMBO_TAG_H2: return ElementType::HEADING2;
|
||||
case GUMBO_TAG_H3: return ElementType::HEADING3;
|
||||
case GUMBO_TAG_H4: return ElementType::HEADING4;
|
||||
case GUMBO_TAG_H5: return ElementType::HEADING5;
|
||||
case GUMBO_TAG_H6: return ElementType::HEADING6;
|
||||
case GUMBO_TAG_P: return ElementType::PARAGRAPH;
|
||||
case GUMBO_TAG_A: return ElementType::LINK;
|
||||
case GUMBO_TAG_LI: return ElementType::LIST_ITEM;
|
||||
case GUMBO_TAG_BLOCKQUOTE: return ElementType::BLOCKQUOTE;
|
||||
case GUMBO_TAG_PRE: return ElementType::CODE_BLOCK;
|
||||
case GUMBO_TAG_HR: return ElementType::HORIZONTAL_RULE;
|
||||
case GUMBO_TAG_BR: return ElementType::LINE_BREAK;
|
||||
case GUMBO_TAG_TABLE: return ElementType::TABLE;
|
||||
case GUMBO_TAG_IMG: return ElementType::IMAGE;
|
||||
case GUMBO_TAG_FORM: return ElementType::FORM;
|
||||
case GUMBO_TAG_INPUT: return ElementType::INPUT;
|
||||
case GUMBO_TAG_TEXTAREA: return ElementType::TEXTAREA;
|
||||
case GUMBO_TAG_SELECT: return ElementType::SELECT;
|
||||
case GUMBO_TAG_OPTION: return ElementType::OPTION;
|
||||
case GUMBO_TAG_BUTTON: return ElementType::BUTTON;
|
||||
default: return ElementType::TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
std::string DomTreeBuilder::resolve_url(const std::string& url, const std::string& base_url) {
|
||||
if (url.empty()) return "";
|
||||
|
||||
// 绝对URL(http://或https://)
|
||||
if (url.find("http://") == 0 || url.find("https://") == 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 协议相对URL(//example.com)
|
||||
if (url.size() >= 2 && url[0] == '/' && url[1] == '/') {
|
||||
// 从base_url提取协议
|
||||
size_t proto_end = base_url.find("://");
|
||||
if (proto_end != std::string::npos) {
|
||||
return base_url.substr(0, proto_end) + ":" + url;
|
||||
}
|
||||
return "https:" + url;
|
||||
}
|
||||
|
||||
if (base_url.empty()) return url;
|
||||
|
||||
// 绝对路径(/path)
|
||||
if (url[0] == '/') {
|
||||
// 提取base_url的scheme和host
|
||||
size_t proto_end = base_url.find("://");
|
||||
if (proto_end == std::string::npos) return url;
|
||||
|
||||
size_t host_start = proto_end + 3;
|
||||
size_t path_start = base_url.find('/', host_start);
|
||||
|
||||
std::string base_origin;
|
||||
if (path_start != std::string::npos) {
|
||||
base_origin = base_url.substr(0, path_start);
|
||||
} else {
|
||||
base_origin = base_url;
|
||||
}
|
||||
|
||||
return base_origin + url;
|
||||
}
|
||||
|
||||
// 相对路径(relative/path)
|
||||
// 找到base_url的路径部分
|
||||
size_t proto_end = base_url.find("://");
|
||||
if (proto_end == std::string::npos) return url;
|
||||
|
||||
size_t host_start = proto_end + 3;
|
||||
size_t path_start = base_url.find('/', host_start);
|
||||
|
||||
std::string base_path;
|
||||
if (path_start != std::string::npos) {
|
||||
// 找到最后一个/
|
||||
size_t last_slash = base_url.rfind('/');
|
||||
if (last_slash != std::string::npos) {
|
||||
base_path = base_url.substr(0, last_slash + 1);
|
||||
} else {
|
||||
base_path = base_url + "/";
|
||||
}
|
||||
} else {
|
||||
base_path = base_url + "/";
|
||||
}
|
||||
|
||||
return base_path + url;
|
||||
}
|
||||
|
||||
const std::map<std::string, std::string>& DomTreeBuilder::get_entity_map() {
|
||||
static std::map<std::string, std::string> entity_map = {
|
||||
{" ", " "}, {"<", "<"}, {">", ">"},
|
||||
{"&", "&"}, {""", "\""}, {"'", "'"},
|
||||
{"©", "©"}, {"®", "®"}, {"™", "™"},
|
||||
{"€", "€"}, {"£", "£"}, {"¥", "¥"},
|
||||
{"¢", "¢"}, {"§", "§"}, {"¶", "¶"},
|
||||
{"†", "†"}, {"‡", "‡"}, {"•", "•"},
|
||||
{"…", "…"}, {"′", "′"}, {"″", "″"},
|
||||
{"‹", "‹"}, {"›", "›"}, {"«", "«"},
|
||||
{"»", "»"}, {"‘", "'"}, {"’", "'"},
|
||||
{"“", "\u201C"}, {"”", "\u201D"}, {"—", "—"},
|
||||
{"–", "–"}, {"¡", "¡"}, {"¿", "¿"},
|
||||
{"×", "×"}, {"÷", "÷"}, {"±", "±"},
|
||||
{"°", "°"}, {"µ", "µ"}, {"·", "·"},
|
||||
{"¼", "¼"}, {"½", "½"}, {"¾", "¾"},
|
||||
{"¹", "¹"}, {"²", "²"}, {"³", "³"},
|
||||
{"α", "α"}, {"β", "β"}, {"γ", "γ"},
|
||||
{"δ", "δ"}, {"ε", "ε"}, {"θ", "θ"},
|
||||
{"λ", "λ"}, {"μ", "μ"}, {"π", "π"},
|
||||
{"σ", "σ"}, {"τ", "τ"}, {"φ", "φ"},
|
||||
{"ω", "ω"}
|
||||
};
|
||||
return entity_map;
|
||||
}
|
||||
|
||||
std::string DomTreeBuilder::decode_html_entities(const std::string& text) {
|
||||
std::string result = text;
|
||||
const auto& entity_map = get_entity_map();
|
||||
|
||||
// 替换命名实体
|
||||
for (const auto& [entity, replacement] : entity_map) {
|
||||
size_t pos = 0;
|
||||
while ((pos = result.find(entity, pos)) != std::string::npos) {
|
||||
result.replace(pos, entity.length(), replacement);
|
||||
pos += replacement.length();
|
||||
}
|
||||
}
|
||||
|
||||
// 替换数字实体 { 或 «
|
||||
std::regex numeric_entity(R"(&#(\d+);)");
|
||||
std::regex hex_entity(R"(&#x([0-9A-Fa-f]+);)");
|
||||
|
||||
// 处理十进制数字实体
|
||||
std::string temp;
|
||||
size_t last_pos = 0;
|
||||
std::smatch match;
|
||||
std::string::const_iterator search_start(result.cbegin());
|
||||
|
||||
while (std::regex_search(search_start, result.cend(), match, numeric_entity)) {
|
||||
size_t match_pos = match.position() + std::distance(result.cbegin(), search_start);
|
||||
temp += result.substr(last_pos, match_pos - last_pos);
|
||||
|
||||
int code = std::stoi(match[1].str());
|
||||
if (code > 0 && code < 0x110000) {
|
||||
// 简单的UTF-8编码(仅支持基本多文种平面)
|
||||
if (code < 0x80) {
|
||||
temp += static_cast<char>(code);
|
||||
} else if (code < 0x800) {
|
||||
temp += static_cast<char>(0xC0 | (code >> 6));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
} else if (code < 0x10000) {
|
||||
temp += static_cast<char>(0xE0 | (code >> 12));
|
||||
temp += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
} else {
|
||||
temp += static_cast<char>(0xF0 | (code >> 18));
|
||||
temp += static_cast<char>(0x80 | ((code >> 12) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
last_pos = match_pos + match[0].length();
|
||||
search_start = result.cbegin() + last_pos;
|
||||
}
|
||||
temp += result.substr(last_pos);
|
||||
result = temp;
|
||||
|
||||
// 处理十六进制数字实体
|
||||
temp.clear();
|
||||
last_pos = 0;
|
||||
search_start = result.cbegin();
|
||||
|
||||
while (std::regex_search(search_start, result.cend(), match, hex_entity)) {
|
||||
size_t match_pos = match.position() + std::distance(result.cbegin(), search_start);
|
||||
temp += result.substr(last_pos, match_pos - last_pos);
|
||||
|
||||
int code = std::stoi(match[1].str(), nullptr, 16);
|
||||
if (code > 0 && code < 0x110000) {
|
||||
if (code < 0x80) {
|
||||
temp += static_cast<char>(code);
|
||||
} else if (code < 0x800) {
|
||||
temp += static_cast<char>(0xC0 | (code >> 6));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
} else if (code < 0x10000) {
|
||||
temp += static_cast<char>(0xE0 | (code >> 12));
|
||||
temp += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
} else {
|
||||
temp += static_cast<char>(0xF0 | (code >> 18));
|
||||
temp += static_cast<char>(0x80 | ((code >> 12) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||
temp += static_cast<char>(0x80 | (code & 0x3F));
|
||||
}
|
||||
}
|
||||
|
||||
last_pos = match_pos + match[0].length();
|
||||
search_start = result.cbegin() + last_pos;
|
||||
}
|
||||
temp += result.substr(last_pos);
|
||||
|
||||
return temp;
|
||||
}
|
||||
114
src/dom_tree.h
114
src/dom_tree.h
|
|
@ -1,114 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "html_parser.h"
|
||||
#include "render/image.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <map>
|
||||
|
||||
// Forward declaration for gumbo
|
||||
struct GumboInternalNode;
|
||||
struct GumboInternalOutput;
|
||||
typedef struct GumboInternalNode GumboNode;
|
||||
typedef struct GumboInternalOutput GumboOutput;
|
||||
|
||||
// DOM节点类型
|
||||
enum class NodeType {
|
||||
ELEMENT, // 元素节点(h1, p, div等)
|
||||
TEXT, // 文本节点
|
||||
DOCUMENT // 文档根节点
|
||||
};
|
||||
|
||||
// DOM节点结构
|
||||
struct DomNode {
|
||||
NodeType node_type;
|
||||
ElementType element_type; // 复用现有的ElementType
|
||||
std::string tag_name; // "div", "p", "h1"等
|
||||
std::string text_content; // TEXT节点的文本内容
|
||||
|
||||
// 树结构
|
||||
std::vector<std::unique_ptr<DomNode>> children;
|
||||
DomNode* parent = nullptr; // 非拥有指针
|
||||
|
||||
// 链接属性
|
||||
std::string href;
|
||||
int link_index = -1; // -1表示非链接
|
||||
int field_index = -1; // -1表示非表单字段
|
||||
|
||||
// 图片属性
|
||||
std::string img_src; // 图片URL
|
||||
std::string alt_text; // 图片alt文本
|
||||
int img_width = -1; // 图片宽度 (-1表示未指定)
|
||||
int img_height = -1; // 图片高度 (-1表示未指定)
|
||||
tut::ImageData image_data; // 解码后的图片数据
|
||||
|
||||
// 表格属性
|
||||
bool is_table_header = false;
|
||||
int colspan = 1;
|
||||
int rowspan = 1;
|
||||
|
||||
// 表单属性
|
||||
std::string action;
|
||||
std::string method;
|
||||
std::string name;
|
||||
std::string value;
|
||||
std::string input_type; // text, password, checkbox, radio, submit, hidden
|
||||
std::string placeholder;
|
||||
bool checked = false;
|
||||
int form_id = -1;
|
||||
|
||||
// 辅助方法
|
||||
bool is_block_element() const;
|
||||
bool is_inline_element() const;
|
||||
bool should_render() const; // 是否应该渲染(过滤script、style等)
|
||||
std::string get_all_text() const; // 递归获取所有文本内容
|
||||
};
|
||||
|
||||
// 文档树结构
|
||||
struct DocumentTree {
|
||||
std::unique_ptr<DomNode> root;
|
||||
std::vector<Link> links; // 全局链接列表
|
||||
std::vector<DomNode*> form_fields; // 全局表单字段列表 (非拥有指针)
|
||||
std::vector<DomNode*> images; // 全局图片列表 (非拥有指针)
|
||||
std::string title;
|
||||
std::string url;
|
||||
};
|
||||
|
||||
// DOM树构建器
|
||||
class DomTreeBuilder {
|
||||
public:
|
||||
DomTreeBuilder();
|
||||
~DomTreeBuilder();
|
||||
|
||||
// 从HTML构建DOM树
|
||||
DocumentTree build(const std::string& html, const std::string& base_url);
|
||||
|
||||
private:
|
||||
// 将GumboNode转换为DomNode
|
||||
std::unique_ptr<DomNode> convert_node(
|
||||
GumboNode* gumbo_node,
|
||||
std::vector<Link>& links,
|
||||
std::vector<DomNode*>& form_fields,
|
||||
std::vector<DomNode*>& images,
|
||||
const std::string& base_url
|
||||
);
|
||||
|
||||
// 提取文档标题
|
||||
std::string extract_title(DomNode* root);
|
||||
|
||||
// 从GumboNode提取所有文本
|
||||
std::string extract_text_from_gumbo(GumboNode* node);
|
||||
|
||||
// 将GumboTag映射为ElementType
|
||||
ElementType map_gumbo_tag_to_element_type(int gumbo_tag);
|
||||
|
||||
// URL解析
|
||||
std::string resolve_url(const std::string& url, const std::string& base_url);
|
||||
|
||||
// HTML实体解码
|
||||
std::string decode_html_entities(const std::string& text);
|
||||
|
||||
// HTML实体映射表
|
||||
static const std::map<std::string, std::string>& get_entity_map();
|
||||
};
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
#include "html_parser.h"
|
||||
#include "dom_tree.h"
|
||||
#include <stdexcept>
|
||||
|
||||
// ============================================================================
|
||||
// HtmlParser::Impl 实现
|
||||
// ============================================================================
|
||||
|
||||
class HtmlParser::Impl {
|
||||
public:
|
||||
bool keep_code_blocks = true;
|
||||
bool keep_lists = true;
|
||||
|
||||
DomTreeBuilder tree_builder;
|
||||
|
||||
DocumentTree parse_tree(const std::string& html, const std::string& base_url) {
|
||||
return tree_builder.build(html, base_url);
|
||||
}
|
||||
|
||||
// 将DocumentTree转换为ParsedDocument(向后兼容)
|
||||
ParsedDocument convert_to_parsed_document(const DocumentTree& tree) {
|
||||
ParsedDocument doc;
|
||||
doc.title = tree.title;
|
||||
doc.url = tree.url;
|
||||
doc.links = tree.links;
|
||||
|
||||
// 递归遍历DOM树,收集ContentElement
|
||||
if (tree.root) {
|
||||
collect_content_elements(tree.root.get(), doc.elements);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
private:
|
||||
void collect_content_elements(DomNode* node, std::vector<ContentElement>& elements) {
|
||||
if (!node || !node->should_render()) return;
|
||||
|
||||
if (node->node_type == NodeType::ELEMENT) {
|
||||
ContentElement elem;
|
||||
elem.type = node->element_type;
|
||||
elem.url = node->href;
|
||||
elem.level = 0; // TODO: 根据需要计算层级
|
||||
elem.list_number = 0;
|
||||
elem.nesting_level = 0;
|
||||
|
||||
// 提取文本内容
|
||||
elem.text = node->get_all_text();
|
||||
|
||||
// 收集内联链接
|
||||
collect_inline_links(node, elem.inline_links);
|
||||
|
||||
// 只添加有内容的元素
|
||||
if (!elem.text.empty() || node->element_type == ElementType::HORIZONTAL_RULE) {
|
||||
elements.push_back(elem);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
for (const auto& child : node->children) {
|
||||
collect_content_elements(child.get(), elements);
|
||||
}
|
||||
}
|
||||
|
||||
void collect_inline_links(DomNode* node, std::vector<InlineLink>& links) {
|
||||
if (!node) return;
|
||||
|
||||
if (node->element_type == ElementType::LINK && node->link_index >= 0) {
|
||||
InlineLink link;
|
||||
link.text = node->get_all_text();
|
||||
link.url = node->href;
|
||||
link.link_index = node->link_index;
|
||||
link.start_pos = 0; // 简化:不计算精确位置
|
||||
link.end_pos = link.text.length();
|
||||
links.push_back(link);
|
||||
}
|
||||
|
||||
for (const auto& child : node->children) {
|
||||
collect_inline_links(child.get(), links);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HtmlParser 公共接口实现
|
||||
// ============================================================================
|
||||
|
||||
HtmlParser::HtmlParser() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
||||
HtmlParser::~HtmlParser() = default;
|
||||
|
||||
DocumentTree HtmlParser::parse_tree(const std::string& html, const std::string& base_url) {
|
||||
return pImpl->parse_tree(html, base_url);
|
||||
}
|
||||
|
||||
ParsedDocument HtmlParser::parse(const std::string& html, const std::string& base_url) {
|
||||
// 使用新的DOM树解析,然后转换为旧格式
|
||||
DocumentTree tree = pImpl->parse_tree(html, base_url);
|
||||
return pImpl->convert_to_parsed_document(tree);
|
||||
}
|
||||
|
||||
void HtmlParser::set_keep_code_blocks(bool keep) {
|
||||
pImpl->keep_code_blocks = keep;
|
||||
}
|
||||
|
||||
void HtmlParser::set_keep_lists(bool keep) {
|
||||
pImpl->keep_lists = keep;
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
// Forward declaration
|
||||
struct DocumentTree;
|
||||
|
||||
enum class ElementType {
|
||||
TEXT,
|
||||
HEADING1,
|
||||
HEADING2,
|
||||
HEADING3,
|
||||
HEADING4,
|
||||
HEADING5,
|
||||
HEADING6,
|
||||
PARAGRAPH,
|
||||
LINK,
|
||||
LIST_ITEM,
|
||||
ORDERED_LIST_ITEM,
|
||||
BLOCKQUOTE,
|
||||
CODE_BLOCK,
|
||||
HORIZONTAL_RULE,
|
||||
LINE_BREAK,
|
||||
TABLE,
|
||||
IMAGE,
|
||||
FORM,
|
||||
INPUT,
|
||||
TEXTAREA,
|
||||
SELECT,
|
||||
OPTION,
|
||||
BUTTON,
|
||||
SECTION_START,
|
||||
SECTION_END,
|
||||
NAV_START,
|
||||
NAV_END,
|
||||
HEADER_START,
|
||||
HEADER_END,
|
||||
ASIDE_START,
|
||||
ASIDE_END
|
||||
};
|
||||
|
||||
struct Link {
|
||||
std::string text;
|
||||
std::string url;
|
||||
int position;
|
||||
};
|
||||
|
||||
struct InlineLink {
|
||||
std::string text;
|
||||
std::string url;
|
||||
size_t start_pos; // Position in the text where link starts
|
||||
size_t end_pos; // Position in the text where link ends
|
||||
int link_index; // Index in the document's links array
|
||||
int field_index = -1; // Index in the document's form_fields array
|
||||
};
|
||||
|
||||
struct TableCell {
|
||||
std::string text;
|
||||
std::vector<InlineLink> inline_links;
|
||||
bool is_header;
|
||||
int colspan;
|
||||
int rowspan;
|
||||
};
|
||||
|
||||
struct TableRow {
|
||||
std::vector<TableCell> cells;
|
||||
};
|
||||
|
||||
struct Table {
|
||||
std::vector<TableRow> rows;
|
||||
bool has_header;
|
||||
};
|
||||
|
||||
struct Image {
|
||||
std::string src;
|
||||
std::string alt;
|
||||
int width; // -1 if not specified
|
||||
int height; // -1 if not specified
|
||||
};
|
||||
|
||||
struct FormField {
|
||||
enum Type { TEXT, PASSWORD, CHECKBOX, RADIO, SUBMIT, BUTTON } type;
|
||||
std::string name;
|
||||
std::string value;
|
||||
std::string placeholder;
|
||||
bool checked;
|
||||
};
|
||||
|
||||
struct Form {
|
||||
std::string action;
|
||||
std::string method;
|
||||
std::vector<FormField> fields;
|
||||
};
|
||||
|
||||
struct ContentElement {
|
||||
ElementType type;
|
||||
std::string text;
|
||||
std::string url;
|
||||
int level;
|
||||
int list_number; // For ordered lists
|
||||
int nesting_level; // For nested lists
|
||||
std::vector<InlineLink> inline_links; // Links within this element's text
|
||||
|
||||
// Extended content types
|
||||
Table table_data;
|
||||
Image image_data;
|
||||
Form form_data;
|
||||
};
|
||||
|
||||
struct ParsedDocument {
|
||||
std::string title;
|
||||
std::string url;
|
||||
std::vector<ContentElement> elements;
|
||||
std::vector<Link> links;
|
||||
};
|
||||
|
||||
class HtmlParser {
|
||||
public:
|
||||
HtmlParser();
|
||||
~HtmlParser();
|
||||
|
||||
// 新接口:使用DOM树解析
|
||||
DocumentTree parse_tree(const std::string& html, const std::string& base_url = "");
|
||||
|
||||
// 旧接口:保持向后兼容(已废弃,内部使用parse_tree)
|
||||
ParsedDocument parse(const std::string& html, const std::string& base_url = "");
|
||||
|
||||
void set_keep_code_blocks(bool keep);
|
||||
void set_keep_lists(bool keep);
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
#include "http_client.h"
|
||||
#include <curl/curl.h>
|
||||
#include <stdexcept>
|
||||
|
||||
// 回调函数用于接收文本数据
|
||||
static size_t write_callback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
||||
size_t total_size = size * nmemb;
|
||||
userp->append(static_cast<char*>(contents), total_size);
|
||||
return total_size;
|
||||
}
|
||||
|
||||
// 回调函数用于接收二进制数据
|
||||
static size_t binary_write_callback(void* contents, size_t size, size_t nmemb, std::vector<uint8_t>* userp) {
|
||||
size_t total_size = size * nmemb;
|
||||
uint8_t* data = static_cast<uint8_t*>(contents);
|
||||
userp->insert(userp->end(), data, data + total_size);
|
||||
return total_size;
|
||||
}
|
||||
|
||||
class HttpClient::Impl {
|
||||
public:
|
||||
CURL* curl;
|
||||
long timeout;
|
||||
std::string user_agent;
|
||||
bool follow_redirects;
|
||||
std::string cookie_file;
|
||||
|
||||
Impl() : timeout(30),
|
||||
user_agent("TUT-Browser/1.0 (Terminal User Interface Browser)"),
|
||||
follow_redirects(true) {
|
||||
curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
throw std::runtime_error("Failed to initialize CURL");
|
||||
}
|
||||
// Enable cookie engine by default (in-memory)
|
||||
curl_easy_setopt(curl, CURLOPT_COOKIEFILE, "");
|
||||
// Enable automatic decompression of supported encodings (gzip, deflate, etc.)
|
||||
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
|
||||
}
|
||||
|
||||
~Impl() {
|
||||
if (curl) {
|
||||
curl_easy_cleanup(curl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
HttpClient::HttpClient() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
||||
HttpClient::~HttpClient() = default;
|
||||
|
||||
HttpResponse HttpClient::fetch(const std::string& url) {
|
||||
HttpResponse response;
|
||||
response.status_code = 0;
|
||||
|
||||
if (!pImpl->curl) {
|
||||
response.error_message = "CURL not initialized";
|
||||
return response;
|
||||
}
|
||||
|
||||
// 重置选项 (Note: curl_easy_reset clears cookies setting if not careful,
|
||||
// but here we might want to preserve them or reset and re-apply options)
|
||||
// Actually curl_easy_reset clears ALL options including cookie engine state?
|
||||
// No, it resets options to default. It does NOT clear the cookie engine state (cookies held in memory).
|
||||
// BUT it resets CURLOPT_COOKIEFILE/JAR settings.
|
||||
|
||||
curl_easy_reset(pImpl->curl);
|
||||
|
||||
// Re-apply settings
|
||||
// 设置URL
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
|
||||
|
||||
// 设置写回调
|
||||
std::string response_body;
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEDATA, &response_body);
|
||||
|
||||
// 设置超时
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_TIMEOUT, pImpl->timeout);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
|
||||
// 设置用户代理
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_USERAGENT, pImpl->user_agent.c_str());
|
||||
|
||||
// 设置是否跟随重定向
|
||||
if (pImpl->follow_redirects) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_MAXREDIRS, 10L);
|
||||
}
|
||||
|
||||
// 支持 HTTPS
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
// Cookie settings
|
||||
if (!pImpl->cookie_file.empty()) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, pImpl->cookie_file.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEJAR, pImpl->cookie_file.c_str());
|
||||
} else {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, "");
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
CURLcode res = curl_easy_perform(pImpl->curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
response.error_message = curl_easy_strerror(res);
|
||||
return response;
|
||||
}
|
||||
|
||||
// 获取响应码
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
response.status_code = static_cast<int>(http_code);
|
||||
|
||||
// 获取 Content-Type
|
||||
char* content_type = nullptr;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_CONTENT_TYPE, &content_type);
|
||||
if (content_type) {
|
||||
response.content_type = content_type;
|
||||
}
|
||||
|
||||
response.body = std::move(response_body);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
BinaryResponse HttpClient::fetch_binary(const std::string& url) {
|
||||
BinaryResponse response;
|
||||
response.status_code = 0;
|
||||
|
||||
if (!pImpl->curl) {
|
||||
response.error_message = "CURL not initialized";
|
||||
return response;
|
||||
}
|
||||
|
||||
curl_easy_reset(pImpl->curl);
|
||||
|
||||
// 设置URL
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
|
||||
|
||||
// 设置写回调
|
||||
std::vector<uint8_t> response_data;
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEFUNCTION, binary_write_callback);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEDATA, &response_data);
|
||||
|
||||
// 设置超时
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_TIMEOUT, pImpl->timeout);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
|
||||
// 设置用户代理
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_USERAGENT, pImpl->user_agent.c_str());
|
||||
|
||||
// 设置是否跟随重定向
|
||||
if (pImpl->follow_redirects) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_MAXREDIRS, 10L);
|
||||
}
|
||||
|
||||
// 支持 HTTPS
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
// Cookie settings
|
||||
if (!pImpl->cookie_file.empty()) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, pImpl->cookie_file.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEJAR, pImpl->cookie_file.c_str());
|
||||
} else {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, "");
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
CURLcode res = curl_easy_perform(pImpl->curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
response.error_message = curl_easy_strerror(res);
|
||||
return response;
|
||||
}
|
||||
|
||||
// 获取响应码
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
response.status_code = static_cast<int>(http_code);
|
||||
|
||||
// 获取 Content-Type
|
||||
char* content_type = nullptr;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_CONTENT_TYPE, &content_type);
|
||||
if (content_type) {
|
||||
response.content_type = content_type;
|
||||
}
|
||||
|
||||
response.data = std::move(response_data);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
HttpResponse HttpClient::post(const std::string& url, const std::string& data,
|
||||
const std::string& content_type) {
|
||||
HttpResponse response;
|
||||
response.status_code = 0;
|
||||
|
||||
if (!pImpl->curl) {
|
||||
response.error_message = "CURL not initialized";
|
||||
return response;
|
||||
}
|
||||
|
||||
curl_easy_reset(pImpl->curl);
|
||||
|
||||
// Re-apply settings
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_URL, url.c_str());
|
||||
|
||||
// Set write callback
|
||||
std::string response_body;
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_WRITEDATA, &response_body);
|
||||
|
||||
// Set timeout
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_TIMEOUT, pImpl->timeout);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
|
||||
// Set user agent
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_USERAGENT, pImpl->user_agent.c_str());
|
||||
|
||||
// Set redirect following
|
||||
if (pImpl->follow_redirects) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_MAXREDIRS, 10L);
|
||||
}
|
||||
|
||||
// HTTPS support
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
// Cookie settings
|
||||
if (!pImpl->cookie_file.empty()) {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, pImpl->cookie_file.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEJAR, pImpl->cookie_file.c_str());
|
||||
} else {
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_COOKIEFILE, "");
|
||||
}
|
||||
|
||||
// Enable automatic decompression
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_ACCEPT_ENCODING, "");
|
||||
|
||||
// Set POST method
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_POST, 1L);
|
||||
|
||||
// Set POST data
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_POSTFIELDS, data.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_POSTFIELDSIZE, data.length());
|
||||
|
||||
// Set Content-Type header
|
||||
struct curl_slist* headers = nullptr;
|
||||
std::string content_type_header = "Content-Type: " + content_type;
|
||||
headers = curl_slist_append(headers, content_type_header.c_str());
|
||||
curl_easy_setopt(pImpl->curl, CURLOPT_HTTPHEADER, headers);
|
||||
|
||||
// Perform request
|
||||
CURLcode res = curl_easy_perform(pImpl->curl);
|
||||
|
||||
// Clean up headers
|
||||
curl_slist_free_all(headers);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
response.error_message = curl_easy_strerror(res);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Get response code
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
response.status_code = static_cast<int>(http_code);
|
||||
|
||||
// Get Content-Type
|
||||
char* resp_content_type = nullptr;
|
||||
curl_easy_getinfo(pImpl->curl, CURLINFO_CONTENT_TYPE, &resp_content_type);
|
||||
if (resp_content_type) {
|
||||
response.content_type = resp_content_type;
|
||||
}
|
||||
|
||||
response.body = std::move(response_body);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
void HttpClient::set_timeout(long timeout_seconds) {
|
||||
pImpl->timeout = timeout_seconds;
|
||||
}
|
||||
|
||||
void HttpClient::set_user_agent(const std::string& user_agent) {
|
||||
pImpl->user_agent = user_agent;
|
||||
}
|
||||
|
||||
void HttpClient::set_follow_redirects(bool follow) {
|
||||
pImpl->follow_redirects = follow;
|
||||
}
|
||||
|
||||
void HttpClient::enable_cookies(const std::string& cookie_file) {
|
||||
pImpl->cookie_file = cookie_file;
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
struct HttpResponse {
|
||||
int status_code;
|
||||
std::string body;
|
||||
std::string content_type;
|
||||
std::string error_message;
|
||||
|
||||
bool is_success() const {
|
||||
return status_code >= 200 && status_code < 300;
|
||||
}
|
||||
|
||||
bool is_image() const {
|
||||
return content_type.find("image/") == 0;
|
||||
}
|
||||
};
|
||||
|
||||
struct BinaryResponse {
|
||||
int status_code;
|
||||
std::vector<uint8_t> data;
|
||||
std::string content_type;
|
||||
std::string error_message;
|
||||
|
||||
bool is_success() const {
|
||||
return status_code >= 200 && status_code < 300;
|
||||
}
|
||||
};
|
||||
|
||||
class HttpClient {
|
||||
public:
|
||||
HttpClient();
|
||||
~HttpClient();
|
||||
|
||||
HttpResponse fetch(const std::string& url);
|
||||
BinaryResponse fetch_binary(const std::string& url);
|
||||
HttpResponse post(const std::string& url, const std::string& data,
|
||||
const std::string& content_type = "application/x-www-form-urlencoded");
|
||||
void set_timeout(long timeout_seconds);
|
||||
void set_user_agent(const std::string& user_agent);
|
||||
void set_follow_redirects(bool follow);
|
||||
void enable_cookies(const std::string& cookie_file = "");
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
|
@ -1,378 +0,0 @@
|
|||
#include "input_handler.h"
|
||||
#include <curses.h>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
class InputHandler::Impl {
|
||||
public:
|
||||
InputMode mode = InputMode::NORMAL;
|
||||
std::string buffer;
|
||||
std::string count_buffer;
|
||||
std::function<void(const std::string&)> status_callback;
|
||||
|
||||
void set_status(const std::string& msg) {
|
||||
if (status_callback) {
|
||||
status_callback(msg);
|
||||
}
|
||||
}
|
||||
|
||||
InputResult process_normal_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
result.number = 0;
|
||||
result.has_count = false;
|
||||
result.count = 1;
|
||||
|
||||
// Handle multi-char commands like 'gg', 'm', '
|
||||
if (!buffer.empty()) {
|
||||
if (buffer == "m") {
|
||||
// Set mark with letter
|
||||
if (std::isalpha(ch)) {
|
||||
result.action = Action::SET_MARK;
|
||||
result.text = std::string(1, static_cast<char>(ch));
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
}
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
} else if (buffer == "'") {
|
||||
// Jump to mark
|
||||
if (std::isalpha(ch)) {
|
||||
result.action = Action::GOTO_MARK;
|
||||
result.text = std::string(1, static_cast<char>(ch));
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
}
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle digit input for count
|
||||
if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) {
|
||||
count_buffer += static_cast<char>(ch);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!count_buffer.empty()) {
|
||||
result.has_count = true;
|
||||
result.count = std::stoi(count_buffer);
|
||||
}
|
||||
|
||||
switch (ch) {
|
||||
case 'j':
|
||||
case KEY_DOWN:
|
||||
result.action = Action::SCROLL_DOWN;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'k':
|
||||
case KEY_UP:
|
||||
result.action = Action::SCROLL_UP;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'h':
|
||||
case KEY_LEFT:
|
||||
result.action = Action::GO_BACK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'l':
|
||||
case KEY_RIGHT:
|
||||
result.action = Action::GO_FORWARD;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 4:
|
||||
case ' ':
|
||||
result.action = Action::SCROLL_PAGE_DOWN;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 21:
|
||||
case 'b':
|
||||
result.action = Action::SCROLL_PAGE_UP;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'g':
|
||||
buffer += 'g';
|
||||
if (buffer == "gg") {
|
||||
result.action = Action::GOTO_TOP;
|
||||
buffer.clear();
|
||||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'G':
|
||||
if (result.has_count) {
|
||||
result.action = Action::GOTO_LINE;
|
||||
result.number = result.count;
|
||||
} else {
|
||||
result.action = Action::GOTO_BOTTOM;
|
||||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '/':
|
||||
mode = InputMode::SEARCH;
|
||||
buffer = "/";
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'n':
|
||||
result.action = Action::SEARCH_NEXT;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'N':
|
||||
result.action = Action::SEARCH_PREV;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '\t':
|
||||
result.action = Action::NEXT_LINK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case KEY_BTAB:
|
||||
case 'T':
|
||||
result.action = Action::PREV_LINK;
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '\n':
|
||||
case '\r':
|
||||
// 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;
|
||||
}
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'f':
|
||||
// 'f' command: vimium-style link hints
|
||||
result.action = Action::SHOW_LINK_HINTS;
|
||||
mode = InputMode::LINK_HINTS;
|
||||
buffer.clear();
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case 'm':
|
||||
// Set mark (wait for next char)
|
||||
buffer = "m";
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case '\'':
|
||||
// Jump to mark (wait for next char)
|
||||
buffer = "'";
|
||||
count_buffer.clear();
|
||||
break;
|
||||
case ':':
|
||||
mode = InputMode::COMMAND;
|
||||
buffer = ":";
|
||||
break;
|
||||
case 'r':
|
||||
result.action = Action::REFRESH;
|
||||
break;
|
||||
case 'q':
|
||||
result.action = Action::QUIT;
|
||||
break;
|
||||
case '?':
|
||||
result.action = Action::HELP;
|
||||
break;
|
||||
case 'B':
|
||||
result.action = Action::ADD_BOOKMARK;
|
||||
break;
|
||||
case 'D':
|
||||
result.action = Action::REMOVE_BOOKMARK;
|
||||
break;
|
||||
default:
|
||||
buffer.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_command_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
std::string command = buffer.substr(1);
|
||||
|
||||
if (command == "q" || command == "quit") {
|
||||
result.action = Action::QUIT;
|
||||
} else if (command == "h" || command == "help") {
|
||||
result.action = Action::HELP;
|
||||
} else if (command == "r" || command == "refresh") {
|
||||
result.action = Action::REFRESH;
|
||||
} else if (command.rfind("o ", 0) == 0 || command.rfind("open ", 0) == 0) {
|
||||
size_t space_pos = command.find(' ');
|
||||
if (space_pos != std::string::npos) {
|
||||
result.action = Action::OPEN_URL;
|
||||
result.text = command.substr(space_pos + 1);
|
||||
}
|
||||
} else if (command == "bookmarks" || command == "bm" || command == "b") {
|
||||
result.action = Action::SHOW_BOOKMARKS;
|
||||
} else if (!command.empty() && std::isdigit(command[0])) {
|
||||
try {
|
||||
result.action = Action::GOTO_LINE;
|
||||
result.number = std::stoi(command);
|
||||
} catch (...) {
|
||||
set_status("Invalid line number");
|
||||
}
|
||||
}
|
||||
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == 27) {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
if (buffer.length() > 1) {
|
||||
buffer.pop_back();
|
||||
} else {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
}
|
||||
} else if (std::isprint(ch)) {
|
||||
buffer += static_cast<char>(ch);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_search_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
if (buffer.length() > 1) {
|
||||
result.action = Action::SEARCH_FORWARD;
|
||||
result.text = buffer.substr(1);
|
||||
}
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == 27) {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
if (buffer.length() > 1) {
|
||||
buffer.pop_back();
|
||||
} else {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
}
|
||||
} else if (std::isprint(ch)) {
|
||||
buffer += static_cast<char>(ch);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_link_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (std::isdigit(ch)) {
|
||||
buffer += static_cast<char>(ch);
|
||||
} else if (ch == '\n' || ch == '\r') {
|
||||
// Follow the link number entered
|
||||
if (buffer.length() > 1) {
|
||||
try {
|
||||
int link_num = std::stoi(buffer.substr(1));
|
||||
result.action = Action::FOLLOW_LINK_NUM;
|
||||
result.number = link_num;
|
||||
} catch (...) {
|
||||
set_status("Invalid link number");
|
||||
}
|
||||
}
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == 27) {
|
||||
// ESC cancels
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
if (buffer.length() > 1) {
|
||||
buffer.pop_back();
|
||||
} else {
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
InputResult process_link_hints_mode(int ch) {
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
|
||||
if (ch == 27) {
|
||||
// ESC cancels link hints mode
|
||||
mode = InputMode::NORMAL;
|
||||
buffer.clear();
|
||||
return result;
|
||||
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||
// Backspace removes last character
|
||||
if (!buffer.empty()) {
|
||||
buffer.pop_back();
|
||||
} else {
|
||||
mode = InputMode::NORMAL;
|
||||
}
|
||||
return result;
|
||||
} else if (std::isalpha(ch)) {
|
||||
// Add character to buffer
|
||||
buffer += std::tolower(static_cast<char>(ch));
|
||||
|
||||
// Try to match link hint
|
||||
result.action = Action::FOLLOW_LINK_HINT;
|
||||
result.text = buffer;
|
||||
|
||||
// Mode will be reset by browser if link is followed
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
||||
|
||||
InputHandler::~InputHandler() = default;
|
||||
|
||||
InputResult InputHandler::handle_key(int ch) {
|
||||
switch (pImpl->mode) {
|
||||
case InputMode::NORMAL:
|
||||
return pImpl->process_normal_mode(ch);
|
||||
case InputMode::COMMAND:
|
||||
return pImpl->process_command_mode(ch);
|
||||
case InputMode::SEARCH:
|
||||
return pImpl->process_search_mode(ch);
|
||||
case InputMode::LINK:
|
||||
return pImpl->process_link_mode(ch);
|
||||
case InputMode::LINK_HINTS:
|
||||
return pImpl->process_link_hints_mode(ch);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
InputResult result;
|
||||
result.action = Action::NONE;
|
||||
return result;
|
||||
}
|
||||
|
||||
InputMode InputHandler::get_mode() const {
|
||||
return pImpl->mode;
|
||||
}
|
||||
|
||||
std::string InputHandler::get_buffer() const {
|
||||
return pImpl->buffer;
|
||||
}
|
||||
|
||||
void InputHandler::reset() {
|
||||
pImpl->mode = InputMode::NORMAL;
|
||||
pImpl->buffer.clear();
|
||||
pImpl->count_buffer.clear();
|
||||
}
|
||||
|
||||
void InputHandler::set_status_callback(std::function<void(const std::string&)> callback) {
|
||||
pImpl->status_callback = callback;
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
enum class InputMode {
|
||||
NORMAL,
|
||||
COMMAND,
|
||||
SEARCH,
|
||||
LINK,
|
||||
LINK_HINTS // Vimium-style 'f' mode
|
||||
};
|
||||
|
||||
enum class Action {
|
||||
NONE,
|
||||
SCROLL_UP,
|
||||
SCROLL_DOWN,
|
||||
SCROLL_PAGE_UP,
|
||||
SCROLL_PAGE_DOWN,
|
||||
GOTO_TOP,
|
||||
GOTO_BOTTOM,
|
||||
GOTO_LINE,
|
||||
SEARCH_FORWARD,
|
||||
SEARCH_NEXT,
|
||||
SEARCH_PREV,
|
||||
NEXT_LINK,
|
||||
PREV_LINK,
|
||||
FOLLOW_LINK,
|
||||
GOTO_LINK, // Jump to specific link by number
|
||||
FOLLOW_LINK_NUM, // Follow specific link by number (f command)
|
||||
SHOW_LINK_HINTS, // Activate link hints mode ('f')
|
||||
FOLLOW_LINK_HINT, // Follow link by hint letters
|
||||
GO_BACK,
|
||||
GO_FORWARD,
|
||||
OPEN_URL,
|
||||
REFRESH,
|
||||
QUIT,
|
||||
HELP,
|
||||
SET_MARK, // Set a mark (m + letter)
|
||||
GOTO_MARK, // Jump to mark (' + letter)
|
||||
ADD_BOOKMARK, // Add current page to bookmarks (B)
|
||||
REMOVE_BOOKMARK, // Remove current page from bookmarks (D)
|
||||
SHOW_BOOKMARKS // Show bookmarks page (:bookmarks)
|
||||
};
|
||||
|
||||
struct InputResult {
|
||||
Action action;
|
||||
std::string text;
|
||||
int number;
|
||||
bool has_count;
|
||||
int count;
|
||||
};
|
||||
|
||||
class InputHandler {
|
||||
public:
|
||||
InputHandler();
|
||||
~InputHandler();
|
||||
|
||||
InputResult handle_key(int ch);
|
||||
InputMode get_mode() const;
|
||||
std::string get_buffer() const;
|
||||
void reset();
|
||||
void set_status_callback(std::function<void(const std::string&)> callback);
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
407
src/main.cpp
407
src/main.cpp
|
|
@ -1,47 +1,396 @@
|
|||
#include "browser.h"
|
||||
#include <iostream>
|
||||
#include <cstring>
|
||||
/**
|
||||
* @file main.cpp
|
||||
* @brief TUT 程序入口
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
void print_usage(const char* prog_name) {
|
||||
std::cout << "TUT - Terminal User Interface Browser\n"
|
||||
<< "A vim-style terminal web browser for comfortable reading\n\n"
|
||||
<< "Usage: " << prog_name << " [URL]\n\n"
|
||||
<< "If no URL is provided, the browser will start with a help page.\n\n"
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
#include <chrono>
|
||||
|
||||
#include "tut/version.hpp"
|
||||
#include "core/browser_engine.hpp"
|
||||
#include "core/bookmark_manager.hpp"
|
||||
#include "core/history_manager.hpp"
|
||||
#include "ui/main_window.hpp"
|
||||
#include "utils/logger.hpp"
|
||||
#include "utils/config.hpp"
|
||||
#include "utils/theme.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
void printVersion() {
|
||||
std::cout << tut::PROJECT_NAME << " " << tut::VERSION_STRING << "\n"
|
||||
<< tut::PROJECT_DESCRIPTION << "\n"
|
||||
<< "Homepage: " << tut::PROJECT_HOMEPAGE << "\n"
|
||||
<< "Built with " << tut::COMPILER_ID << " " << tut::COMPILER_VERSION << "\n";
|
||||
}
|
||||
|
||||
void printHelp(const char* prog_name) {
|
||||
std::cout << "TUT - Terminal UI Textual Browser\n"
|
||||
<< "A lightweight terminal browser with btop-style interface\n\n"
|
||||
<< "Usage: " << prog_name << " [OPTIONS] [URL]\n\n"
|
||||
<< "Options:\n"
|
||||
<< " -h, --help Show this help message\n"
|
||||
<< " -v, --version Show version information\n"
|
||||
<< " -c, --config Specify config file path\n"
|
||||
<< " -t, --theme Specify theme name\n"
|
||||
<< " -d, --debug Enable debug logging\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";
|
||||
<< " " << prog_name << " --theme nord https://github.com\n\n"
|
||||
<< "Keyboard shortcuts:\n"
|
||||
<< " j/k, ↓/↑ Scroll down/up\n"
|
||||
<< " g/G Go to top/bottom\n"
|
||||
<< " Space Page down\n"
|
||||
<< " b Page up\n"
|
||||
<< " Tab Next link\n"
|
||||
<< " Shift+Tab Previous link\n"
|
||||
<< " Enter Follow link\n"
|
||||
<< " Backspace Go back\n"
|
||||
<< " f Go forward\n"
|
||||
<< " / Search in page\n"
|
||||
<< " n/N Next/previous search result\n"
|
||||
<< " Ctrl+L Focus address bar\n"
|
||||
<< " F1 Help\n"
|
||||
<< " F2 Bookmarks\n"
|
||||
<< " F3 History\n"
|
||||
<< " F5, r Refresh\n"
|
||||
<< " Ctrl+D Add bookmark\n"
|
||||
<< " Ctrl+Q, F10 Quit\n";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
std::string initial_url;
|
||||
using namespace tut;
|
||||
|
||||
if (argc > 1) {
|
||||
if (std::strcmp(argv[1], "-h") == 0 || std::strcmp(argv[1], "--help") == 0) {
|
||||
print_usage(argv[0]);
|
||||
std::string initial_url;
|
||||
std::string config_file;
|
||||
std::string theme_name;
|
||||
bool debug_mode = false;
|
||||
|
||||
// 解析命令行参数
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "-h") == 0 || std::strcmp(argv[i], "--help") == 0) {
|
||||
printHelp(argv[0]);
|
||||
return 0;
|
||||
}
|
||||
initial_url = argv[1];
|
||||
if (std::strcmp(argv[i], "-v") == 0 || std::strcmp(argv[i], "--version") == 0) {
|
||||
printVersion();
|
||||
return 0;
|
||||
}
|
||||
if (std::strcmp(argv[i], "-d") == 0 || std::strcmp(argv[i], "--debug") == 0) {
|
||||
debug_mode = true;
|
||||
continue;
|
||||
}
|
||||
if ((std::strcmp(argv[i], "-c") == 0 || std::strcmp(argv[i], "--config") == 0) &&
|
||||
i + 1 < argc) {
|
||||
config_file = argv[++i];
|
||||
continue;
|
||||
}
|
||||
if ((std::strcmp(argv[i], "-t") == 0 || std::strcmp(argv[i], "--theme") == 0) &&
|
||||
i + 1 < argc) {
|
||||
theme_name = argv[++i];
|
||||
continue;
|
||||
}
|
||||
// 假定其他参数是 URL
|
||||
if (argv[i][0] != '-') {
|
||||
initial_url = argv[i];
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化日志系统
|
||||
Logger& logger = Logger::instance();
|
||||
logger.setLevel(debug_mode ? LogLevel::Debug : LogLevel::Info);
|
||||
|
||||
LOG_INFO << "Starting TUT " << VERSION_STRING;
|
||||
|
||||
// 加载配置
|
||||
Config& config = Config::instance();
|
||||
if (!config_file.empty()) {
|
||||
config.load(config_file);
|
||||
} else {
|
||||
std::string default_config = config.getConfigPath() + "/config.toml";
|
||||
config.load(default_config);
|
||||
}
|
||||
|
||||
// 加载主题
|
||||
ThemeManager& theme_manager = ThemeManager::instance();
|
||||
theme_manager.loadThemesFromDirectory(config.getConfigPath() + "/themes");
|
||||
|
||||
if (!theme_name.empty()) {
|
||||
if (!theme_manager.setTheme(theme_name)) {
|
||||
LOG_WARN << "Theme not found: " << theme_name << ", using default";
|
||||
}
|
||||
} else {
|
||||
theme_manager.setTheme(config.getDefaultTheme());
|
||||
}
|
||||
|
||||
try {
|
||||
Browser browser;
|
||||
browser.run(initial_url);
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Error: " << e.what() << std::endl;
|
||||
// 创建浏览器引擎
|
||||
BrowserEngine engine;
|
||||
|
||||
// 创建书签管理器
|
||||
BookmarkManager bookmarks;
|
||||
|
||||
// 创建历史记录管理器
|
||||
HistoryManager history;
|
||||
|
||||
// 创建主窗口
|
||||
MainWindow window;
|
||||
|
||||
// 设置导航回调
|
||||
window.onNavigate([&engine, &window, &history](const std::string& url) {
|
||||
LOG_INFO << "Navigating to: " << url;
|
||||
window.setLoading(true);
|
||||
|
||||
auto start_time = std::chrono::steady_clock::now();
|
||||
|
||||
if (engine.loadUrl(url)) {
|
||||
auto end_time = std::chrono::steady_clock::now();
|
||||
double elapsed = std::chrono::duration<double>(end_time - start_time).count();
|
||||
|
||||
// Update window content
|
||||
window.setTitle(engine.getTitle());
|
||||
window.setContent(engine.getRenderedContent());
|
||||
window.setUrl(url);
|
||||
|
||||
// Record in history
|
||||
history.recordVisit(engine.getTitle(), url);
|
||||
|
||||
// Convert LinkInfo to DisplayLink
|
||||
std::vector<DisplayLink> display_links;
|
||||
for (const auto& link : engine.extractLinks()) {
|
||||
DisplayLink dl;
|
||||
dl.text = link.text;
|
||||
dl.url = link.url;
|
||||
dl.visited = false;
|
||||
display_links.push_back(dl);
|
||||
}
|
||||
window.setLinks(display_links);
|
||||
|
||||
// Update navigation state
|
||||
window.setCanGoBack(engine.canGoBack());
|
||||
window.setCanGoForward(engine.canGoForward());
|
||||
|
||||
// Update stats (assuming response body size)
|
||||
size_t content_size = engine.getRenderedContent().size();
|
||||
window.setLoadStats(elapsed, content_size, static_cast<int>(display_links.size()));
|
||||
|
||||
window.setStatusMessage("Loaded: " + url);
|
||||
} else {
|
||||
window.setStatusMessage("Failed to load: " + url);
|
||||
}
|
||||
|
||||
window.setLoading(false);
|
||||
});
|
||||
|
||||
// 设置链接点击回调
|
||||
window.onLinkClick([&engine, &window, &history](int index) {
|
||||
auto links = engine.extractLinks();
|
||||
if (index >= 0 && index < static_cast<int>(links.size())) {
|
||||
const std::string& link_url = links[index].url;
|
||||
LOG_INFO << "Following link [" << index + 1 << "]: " << link_url;
|
||||
|
||||
// Trigger navigation
|
||||
window.setLoading(true);
|
||||
|
||||
auto start_time = std::chrono::steady_clock::now();
|
||||
|
||||
if (engine.loadUrl(link_url)) {
|
||||
auto end_time = std::chrono::steady_clock::now();
|
||||
double elapsed = std::chrono::duration<double>(end_time - start_time).count();
|
||||
|
||||
window.setTitle(engine.getTitle());
|
||||
window.setContent(engine.getRenderedContent());
|
||||
window.setUrl(link_url);
|
||||
|
||||
// Record in history
|
||||
history.recordVisit(engine.getTitle(), link_url);
|
||||
|
||||
// Convert LinkInfo to DisplayLink
|
||||
std::vector<DisplayLink> display_links;
|
||||
for (const auto& link : engine.extractLinks()) {
|
||||
DisplayLink dl;
|
||||
dl.text = link.text;
|
||||
dl.url = link.url;
|
||||
dl.visited = false;
|
||||
display_links.push_back(dl);
|
||||
}
|
||||
window.setLinks(display_links);
|
||||
|
||||
window.setCanGoBack(engine.canGoBack());
|
||||
window.setCanGoForward(engine.canGoForward());
|
||||
|
||||
size_t content_size = engine.getRenderedContent().size();
|
||||
window.setLoadStats(elapsed, content_size, static_cast<int>(display_links.size()));
|
||||
|
||||
window.setStatusMessage("Loaded: " + link_url);
|
||||
} else {
|
||||
window.setStatusMessage("Failed to load: " + link_url);
|
||||
}
|
||||
|
||||
window.setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to update bookmark display
|
||||
auto updateBookmarks = [&bookmarks, &window]() {
|
||||
std::vector<DisplayBookmark> display_bookmarks;
|
||||
for (const auto& bm : bookmarks.getAll()) {
|
||||
DisplayBookmark db;
|
||||
db.title = bm.title;
|
||||
db.url = bm.url;
|
||||
display_bookmarks.push_back(db);
|
||||
}
|
||||
window.setBookmarks(display_bookmarks);
|
||||
};
|
||||
|
||||
// Helper to update history display
|
||||
auto updateHistory = [&history, &window]() {
|
||||
std::vector<DisplayBookmark> display_history;
|
||||
for (const auto& entry : history.getRecent(10)) { // Show recent 10
|
||||
DisplayBookmark db;
|
||||
db.title = entry.title;
|
||||
db.url = entry.url;
|
||||
display_history.push_back(db);
|
||||
}
|
||||
window.setHistory(display_history);
|
||||
};
|
||||
|
||||
// Initialize displays
|
||||
updateBookmarks();
|
||||
updateHistory();
|
||||
|
||||
// 设置窗口事件回调
|
||||
window.onEvent([&engine, &window, &bookmarks, &updateBookmarks, &updateHistory](WindowEvent event) {
|
||||
switch (event) {
|
||||
case WindowEvent::AddBookmark:
|
||||
{
|
||||
std::string current_url = engine.getCurrentUrl();
|
||||
std::string current_title = engine.getTitle();
|
||||
if (!current_url.empty() && current_url != "about:blank") {
|
||||
if (bookmarks.contains(current_url)) {
|
||||
bookmarks.remove(current_url);
|
||||
window.setStatusMessage("Removed bookmark: " + current_title);
|
||||
} else {
|
||||
bookmarks.add(current_title, current_url);
|
||||
window.setStatusMessage("Added bookmark: " + current_title);
|
||||
}
|
||||
updateBookmarks();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case WindowEvent::OpenBookmarks:
|
||||
updateBookmarks();
|
||||
break;
|
||||
case WindowEvent::OpenHistory:
|
||||
updateHistory();
|
||||
break;
|
||||
case WindowEvent::Back:
|
||||
if (engine.goBack()) {
|
||||
window.setTitle(engine.getTitle());
|
||||
window.setContent(engine.getRenderedContent());
|
||||
window.setUrl(engine.getCurrentUrl());
|
||||
|
||||
std::vector<DisplayLink> display_links;
|
||||
for (const auto& link : engine.extractLinks()) {
|
||||
DisplayLink dl;
|
||||
dl.text = link.text;
|
||||
dl.url = link.url;
|
||||
dl.visited = false;
|
||||
display_links.push_back(dl);
|
||||
}
|
||||
window.setLinks(display_links);
|
||||
|
||||
window.setCanGoBack(engine.canGoBack());
|
||||
window.setCanGoForward(engine.canGoForward());
|
||||
}
|
||||
break;
|
||||
case WindowEvent::Forward:
|
||||
if (engine.goForward()) {
|
||||
window.setTitle(engine.getTitle());
|
||||
window.setContent(engine.getRenderedContent());
|
||||
window.setUrl(engine.getCurrentUrl());
|
||||
|
||||
std::vector<DisplayLink> display_links;
|
||||
for (const auto& link : engine.extractLinks()) {
|
||||
DisplayLink dl;
|
||||
dl.text = link.text;
|
||||
dl.url = link.url;
|
||||
dl.visited = false;
|
||||
display_links.push_back(dl);
|
||||
}
|
||||
window.setLinks(display_links);
|
||||
|
||||
window.setCanGoBack(engine.canGoBack());
|
||||
window.setCanGoForward(engine.canGoForward());
|
||||
}
|
||||
break;
|
||||
case WindowEvent::Refresh:
|
||||
if (engine.refresh()) {
|
||||
window.setTitle(engine.getTitle());
|
||||
window.setContent(engine.getRenderedContent());
|
||||
|
||||
std::vector<DisplayLink> display_links;
|
||||
for (const auto& link : engine.extractLinks()) {
|
||||
DisplayLink dl;
|
||||
dl.text = link.text;
|
||||
dl.url = link.url;
|
||||
dl.visited = false;
|
||||
display_links.push_back(dl);
|
||||
}
|
||||
window.setLinks(display_links);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化窗口
|
||||
if (!window.init()) {
|
||||
LOG_FATAL << "Failed to initialize window";
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
// 加载初始 URL
|
||||
if (!initial_url.empty()) {
|
||||
engine.loadUrl(initial_url);
|
||||
window.setUrl(initial_url);
|
||||
window.setTitle(engine.getTitle());
|
||||
window.setContent(engine.getRenderedContent());
|
||||
|
||||
std::vector<DisplayLink> display_links;
|
||||
for (const auto& link : engine.extractLinks()) {
|
||||
DisplayLink dl;
|
||||
dl.text = link.text;
|
||||
dl.url = link.url;
|
||||
dl.visited = false;
|
||||
display_links.push_back(dl);
|
||||
}
|
||||
window.setLinks(display_links);
|
||||
window.setCanGoBack(engine.canGoBack());
|
||||
window.setCanGoForward(engine.canGoForward());
|
||||
} else {
|
||||
window.setUrl("about:blank");
|
||||
window.setTitle("TUT - Terminal UI Textual Browser");
|
||||
window.setContent("Welcome to TUT!\n\nPress Ctrl+L to enter a URL.");
|
||||
}
|
||||
|
||||
// 运行主循环
|
||||
int exit_code = window.run();
|
||||
|
||||
LOG_INFO << "TUT exiting with code " << exit_code;
|
||||
return exit_code;
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
LOG_FATAL << "Fatal error: " << e.what();
|
||||
std::cerr << "Error: " << e.what() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,792 +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;
|
||||
|
||||
// 检查是否有解码后的图片数据
|
||||
if (node->image_data.is_valid()) {
|
||||
// 渲染 ASCII Art
|
||||
ImageRenderer renderer;
|
||||
renderer.set_mode(ImageRenderer::Mode::BLOCKS);
|
||||
renderer.set_color_enabled(true);
|
||||
|
||||
// 计算图片最大尺寸(留出左边距)
|
||||
int max_width = content_width_;
|
||||
int max_height = 30; // 限制高度
|
||||
|
||||
// 如果节点指定了尺寸,使用更小的值
|
||||
if (node->img_width > 0) {
|
||||
max_width = std::min(max_width, node->img_width);
|
||||
}
|
||||
if (node->img_height > 0) {
|
||||
max_height = std::min(max_height, node->img_height / 2); // 考虑字符高宽比
|
||||
}
|
||||
|
||||
AsciiImage ascii = renderer.render(node->image_data, max_width, max_height);
|
||||
|
||||
if (!ascii.lines.empty()) {
|
||||
for (size_t i = 0; i < ascii.lines.size(); ++i) {
|
||||
LayoutLine line;
|
||||
line.indent = MARGIN_LEFT;
|
||||
|
||||
// 将每一行作为一个 span
|
||||
// 但由于颜色可能不同,需要逐字符处理
|
||||
const std::string& line_text = ascii.lines[i];
|
||||
const std::vector<uint32_t>& line_colors = ascii.colors[i];
|
||||
|
||||
// 为了效率,尝试合并相同颜色的字符
|
||||
size_t pos = 0;
|
||||
while (pos < line_text.size()) {
|
||||
// 获取当前字符的字节数(UTF-8)
|
||||
int char_bytes = 1;
|
||||
unsigned char c = line_text[pos];
|
||||
if ((c & 0x80) == 0) {
|
||||
char_bytes = 1;
|
||||
} else if ((c & 0xE0) == 0xC0) {
|
||||
char_bytes = 2;
|
||||
} else if ((c & 0xF0) == 0xE0) {
|
||||
char_bytes = 3;
|
||||
} else if ((c & 0xF8) == 0xF0) {
|
||||
char_bytes = 4;
|
||||
}
|
||||
|
||||
// 获取颜色索引(基于显示宽度位置)
|
||||
size_t color_idx = 0;
|
||||
for (size_t j = 0; j < pos; ) {
|
||||
unsigned char ch = line_text[j];
|
||||
int bytes = 1;
|
||||
if ((ch & 0x80) == 0) bytes = 1;
|
||||
else if ((ch & 0xE0) == 0xC0) bytes = 2;
|
||||
else if ((ch & 0xF0) == 0xE0) bytes = 3;
|
||||
else if ((ch & 0xF8) == 0xF0) bytes = 4;
|
||||
color_idx++;
|
||||
j += bytes;
|
||||
}
|
||||
|
||||
uint32_t color = (color_idx < line_colors.size()) ? line_colors[color_idx] : colors::FG_PRIMARY;
|
||||
|
||||
StyledSpan span;
|
||||
span.text = line_text.substr(pos, char_bytes);
|
||||
span.fg = color;
|
||||
span.attrs = ATTR_NONE;
|
||||
line.spans.push_back(span);
|
||||
|
||||
pos += char_bytes;
|
||||
}
|
||||
|
||||
block.lines.push_back(line);
|
||||
}
|
||||
blocks.push_back(block);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到占位符
|
||||
LayoutLine line;
|
||||
line.indent = MARGIN_LEFT;
|
||||
|
||||
std::string placeholder = make_image_placeholder(node->alt_text, node->img_src);
|
||||
|
||||
StyledSpan span;
|
||||
span.text = placeholder;
|
||||
span.fg = colors::FG_DIM; // 使用较暗的颜色表示占位符
|
||||
span.attrs = ATTR_NONE;
|
||||
|
||||
line.spans.push_back(span);
|
||||
block.lines.push_back(line);
|
||||
blocks.push_back(block);
|
||||
}
|
||||
|
||||
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
|
||||
206
src/renderer/html_renderer.cpp
Normal file
206
src/renderer/html_renderer.cpp
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* @file html_renderer.cpp
|
||||
* @brief HTML 渲染器实现
|
||||
*/
|
||||
|
||||
#include "renderer/html_renderer.hpp"
|
||||
#include "core/browser_engine.hpp"
|
||||
#include "core/url_parser.hpp"
|
||||
|
||||
#include <gumbo.h>
|
||||
#include <sstream>
|
||||
|
||||
namespace tut {
|
||||
|
||||
class HtmlRenderer::Impl {
|
||||
public:
|
||||
RenderOptions options_;
|
||||
|
||||
void renderNode(GumboNode* node, std::ostringstream& output,
|
||||
std::vector<LinkInfo>& links, int& link_count) {
|
||||
if (node->type == GUMBO_NODE_TEXT) {
|
||||
output << node->v.text.text;
|
||||
return;
|
||||
}
|
||||
|
||||
if (node->type != GUMBO_NODE_ELEMENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
GumboElement& element = node->v.element;
|
||||
GumboTag tag = element.tag;
|
||||
|
||||
// 跳过不可见元素
|
||||
if (tag == GUMBO_TAG_SCRIPT || tag == GUMBO_TAG_STYLE ||
|
||||
tag == GUMBO_TAG_HEAD || tag == GUMBO_TAG_NOSCRIPT) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理块级元素
|
||||
bool is_block = (tag == GUMBO_TAG_P || tag == GUMBO_TAG_DIV ||
|
||||
tag == GUMBO_TAG_H1 || tag == GUMBO_TAG_H2 ||
|
||||
tag == GUMBO_TAG_H3 || tag == GUMBO_TAG_H4 ||
|
||||
tag == GUMBO_TAG_H5 || tag == GUMBO_TAG_H6 ||
|
||||
tag == GUMBO_TAG_UL || tag == GUMBO_TAG_OL ||
|
||||
tag == GUMBO_TAG_LI || tag == GUMBO_TAG_BR ||
|
||||
tag == GUMBO_TAG_HR || tag == GUMBO_TAG_BLOCKQUOTE ||
|
||||
tag == GUMBO_TAG_PRE || tag == GUMBO_TAG_TABLE ||
|
||||
tag == GUMBO_TAG_TR);
|
||||
|
||||
// 标题格式
|
||||
if (tag >= GUMBO_TAG_H1 && tag <= GUMBO_TAG_H6) {
|
||||
output << "\n";
|
||||
if (options_.use_colors) {
|
||||
output << "\033[1m"; // Bold
|
||||
}
|
||||
}
|
||||
|
||||
// 列表项
|
||||
if (tag == GUMBO_TAG_LI) {
|
||||
output << "\n • ";
|
||||
}
|
||||
|
||||
// 链接
|
||||
if (tag == GUMBO_TAG_A && options_.show_links) {
|
||||
GumboAttribute* href = gumbo_get_attribute(&element.attributes, "href");
|
||||
if (href) {
|
||||
link_count++;
|
||||
LinkInfo link;
|
||||
link.url = href->value;
|
||||
|
||||
// 提取链接文本
|
||||
std::ostringstream link_text;
|
||||
for (unsigned int i = 0; i < element.children.length; ++i) {
|
||||
GumboNode* child = static_cast<GumboNode*>(element.children.data[i]);
|
||||
if (child->type == GUMBO_NODE_TEXT) {
|
||||
link_text << child->v.text.text;
|
||||
}
|
||||
}
|
||||
link.text = link_text.str();
|
||||
links.push_back(link);
|
||||
|
||||
if (options_.use_colors) {
|
||||
output << "\033[4;34m"; // Underline blue
|
||||
}
|
||||
output << "[" << link_count << "]";
|
||||
}
|
||||
}
|
||||
|
||||
// 递归处理子节点
|
||||
for (unsigned int i = 0; i < element.children.length; ++i) {
|
||||
renderNode(static_cast<GumboNode*>(element.children.data[i]),
|
||||
output, links, link_count);
|
||||
}
|
||||
|
||||
// 关闭格式
|
||||
if (tag >= GUMBO_TAG_H1 && tag <= GUMBO_TAG_H6) {
|
||||
if (options_.use_colors) {
|
||||
output << "\033[0m"; // Reset
|
||||
}
|
||||
output << "\n";
|
||||
}
|
||||
|
||||
if (tag == GUMBO_TAG_A && options_.show_links && options_.use_colors) {
|
||||
output << "\033[0m";
|
||||
}
|
||||
|
||||
if (is_block) {
|
||||
output << "\n";
|
||||
}
|
||||
}
|
||||
|
||||
std::string findTitle(GumboNode* node) {
|
||||
if (node->type != GUMBO_NODE_ELEMENT) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (node->v.element.tag == GUMBO_TAG_TITLE) {
|
||||
if (node->v.element.children.length > 0) {
|
||||
GumboNode* child = static_cast<GumboNode*>(node->v.element.children.data[0]);
|
||||
if (child->type == GUMBO_NODE_TEXT) {
|
||||
return child->v.text.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (unsigned int i = 0; i < node->v.element.children.length; ++i) {
|
||||
std::string title = findTitle(
|
||||
static_cast<GumboNode*>(node->v.element.children.data[i]));
|
||||
if (!title.empty()) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
HtmlRenderer::HtmlRenderer() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
HtmlRenderer::~HtmlRenderer() = default;
|
||||
|
||||
RenderResult HtmlRenderer::render(const std::string& html,
|
||||
const RenderOptions& options) {
|
||||
impl_->options_ = options;
|
||||
RenderResult result;
|
||||
|
||||
GumboOutput* output = gumbo_parse(html.c_str());
|
||||
if (!output) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 提取标题
|
||||
result.title = impl_->findTitle(output->root);
|
||||
|
||||
// 渲染内容
|
||||
std::ostringstream text_output;
|
||||
int link_count = 0;
|
||||
impl_->renderNode(output->root, text_output, result.links, link_count);
|
||||
|
||||
result.text = text_output.str();
|
||||
|
||||
gumbo_destroy_output(&kGumboDefaultOptions, output);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string HtmlRenderer::extractTitle(const std::string& html) {
|
||||
GumboOutput* output = gumbo_parse(html.c_str());
|
||||
if (!output) {
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string title = impl_->findTitle(output->root);
|
||||
gumbo_destroy_output(&kGumboDefaultOptions, output);
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
std::vector<LinkInfo> HtmlRenderer::extractLinks(const std::string& html,
|
||||
const std::string& base_url) {
|
||||
RenderOptions options;
|
||||
options.show_links = true;
|
||||
auto result = render(html, options);
|
||||
|
||||
// 解析相对 URL
|
||||
if (!base_url.empty()) {
|
||||
UrlParser parser;
|
||||
for (auto& link : result.links) {
|
||||
if (link.url.find("://") == std::string::npos) {
|
||||
link.url = parser.resolveRelative(base_url, link.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.links;
|
||||
}
|
||||
|
||||
void HtmlRenderer::setOptions(const RenderOptions& options) {
|
||||
impl_->options_ = options;
|
||||
}
|
||||
|
||||
const RenderOptions& HtmlRenderer::getOptions() const {
|
||||
return impl_->options_;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
95
src/renderer/html_renderer.hpp
Normal file
95
src/renderer/html_renderer.hpp
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* @file html_renderer.hpp
|
||||
* @brief HTML 渲染器模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include "../core/types.hpp"
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 渲染选项
|
||||
*/
|
||||
struct RenderOptions {
|
||||
int width{80}; ///< 渲染宽度
|
||||
bool show_links{true}; ///< 显示链接
|
||||
bool show_images{false}; ///< 显示图片 (ASCII art)
|
||||
bool use_colors{true}; ///< 使用颜色
|
||||
int indent_size{2}; ///< 缩进大小
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 渲染结果
|
||||
*/
|
||||
struct RenderResult {
|
||||
std::string text; ///< 渲染后的文本
|
||||
std::vector<LinkInfo> links; ///< 提取的链接
|
||||
std::string title; ///< 页面标题
|
||||
std::string description; ///< 页面描述
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief HTML 渲染器类
|
||||
*
|
||||
* 将 HTML 文档渲染为终端可显示的文本
|
||||
*/
|
||||
class HtmlRenderer {
|
||||
public:
|
||||
/**
|
||||
* @brief 构造函数
|
||||
*/
|
||||
HtmlRenderer();
|
||||
|
||||
/**
|
||||
* @brief 析构函数
|
||||
*/
|
||||
~HtmlRenderer();
|
||||
|
||||
/**
|
||||
* @brief 渲染 HTML 文档
|
||||
* @param html HTML 字符串
|
||||
* @param options 渲染选项
|
||||
* @return 渲染结果
|
||||
*/
|
||||
RenderResult render(const std::string& html,
|
||||
const RenderOptions& options = RenderOptions{});
|
||||
|
||||
/**
|
||||
* @brief 提取页面标题
|
||||
* @param html HTML 字符串
|
||||
* @return 标题
|
||||
*/
|
||||
std::string extractTitle(const std::string& html);
|
||||
|
||||
/**
|
||||
* @brief 提取所有链接
|
||||
* @param html HTML 字符串
|
||||
* @param base_url 基础 URL (用于解析相对链接)
|
||||
* @return 链接列表
|
||||
*/
|
||||
std::vector<LinkInfo> extractLinks(const std::string& html,
|
||||
const std::string& base_url = "");
|
||||
|
||||
/**
|
||||
* @brief 设置渲染选项
|
||||
*/
|
||||
void setOptions(const RenderOptions& options);
|
||||
|
||||
/**
|
||||
* @brief 获取渲染选项
|
||||
*/
|
||||
const RenderOptions& getOptions() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
209
src/renderer/style_parser.cpp
Normal file
209
src/renderer/style_parser.cpp
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* @file style_parser.cpp
|
||||
* @brief 样式解析实现
|
||||
*/
|
||||
|
||||
#include "renderer/style_parser.hpp"
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iomanip>
|
||||
|
||||
namespace tut {
|
||||
|
||||
const std::map<std::string, Color> StyleParser::named_colors_ = {
|
||||
{"black", {0, 0, 0}},
|
||||
{"white", {255, 255, 255}},
|
||||
{"red", {255, 0, 0}},
|
||||
{"green", {0, 128, 0}},
|
||||
{"blue", {0, 0, 255}},
|
||||
{"yellow", {255, 255, 0}},
|
||||
{"cyan", {0, 255, 255}},
|
||||
{"magenta", {255, 0, 255}},
|
||||
{"gray", {128, 128, 128}},
|
||||
{"grey", {128, 128, 128}},
|
||||
{"silver", {192, 192, 192}},
|
||||
{"maroon", {128, 0, 0}},
|
||||
{"olive", {128, 128, 0}},
|
||||
{"navy", {0, 0, 128}},
|
||||
{"purple", {128, 0, 128}},
|
||||
{"teal", {0, 128, 128}},
|
||||
{"orange", {255, 165, 0}},
|
||||
{"pink", {255, 192, 203}},
|
||||
};
|
||||
|
||||
std::optional<Color> Color::fromHex(const std::string& hex) {
|
||||
std::string h = hex;
|
||||
if (!h.empty() && h[0] == '#') {
|
||||
h = h.substr(1);
|
||||
}
|
||||
|
||||
if (h.length() == 3) {
|
||||
// 短格式 #RGB -> #RRGGBB
|
||||
h = std::string(2, h[0]) + std::string(2, h[1]) + std::string(2, h[2]);
|
||||
}
|
||||
|
||||
if (h.length() != 6 && h.length() != 8) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
try {
|
||||
Color color;
|
||||
color.r = static_cast<uint8_t>(std::stoi(h.substr(0, 2), nullptr, 16));
|
||||
color.g = static_cast<uint8_t>(std::stoi(h.substr(2, 2), nullptr, 16));
|
||||
color.b = static_cast<uint8_t>(std::stoi(h.substr(4, 2), nullptr, 16));
|
||||
if (h.length() == 8) {
|
||||
color.a = static_cast<uint8_t>(std::stoi(h.substr(6, 2), nullptr, 16));
|
||||
}
|
||||
return color;
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
std::string Color::toHex() const {
|
||||
std::ostringstream oss;
|
||||
oss << "#" << std::hex << std::uppercase;
|
||||
oss << std::setw(2) << std::setfill('0') << static_cast<int>(r);
|
||||
oss << std::setw(2) << std::setfill('0') << static_cast<int>(g);
|
||||
oss << std::setw(2) << std::setfill('0') << static_cast<int>(b);
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
int Color::toAnsi256() const {
|
||||
// 转换为 ANSI 256 色
|
||||
if (r == g && g == b) {
|
||||
// 灰度
|
||||
if (r < 8) return 16;
|
||||
if (r > 248) return 231;
|
||||
return static_cast<int>(std::round((r - 8.0) / 247.0 * 24)) + 232;
|
||||
}
|
||||
|
||||
// RGB 色
|
||||
int ri = static_cast<int>(std::round(r / 255.0 * 5));
|
||||
int gi = static_cast<int>(std::round(g / 255.0 * 5));
|
||||
int bi = static_cast<int>(std::round(b / 255.0 * 5));
|
||||
return 16 + 36 * ri + 6 * gi + bi;
|
||||
}
|
||||
|
||||
std::string Color::toAnsiEscape(bool foreground) const {
|
||||
std::ostringstream oss;
|
||||
oss << "\033[" << (foreground ? "38" : "48") << ";2;"
|
||||
<< static_cast<int>(r) << ";"
|
||||
<< static_cast<int>(g) << ";"
|
||||
<< static_cast<int>(b) << "m";
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
std::string TextStyle::toAnsiEscape() const {
|
||||
std::ostringstream oss;
|
||||
|
||||
if (bold) oss << "\033[1m";
|
||||
if (italic) oss << "\033[3m";
|
||||
if (underline) oss << "\033[4m";
|
||||
if (strikethrough) oss << "\033[9m";
|
||||
|
||||
if (foreground) {
|
||||
oss << foreground->toAnsiEscape(true);
|
||||
}
|
||||
if (background) {
|
||||
oss << background->toAnsiEscape(false);
|
||||
}
|
||||
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
std::string TextStyle::resetAnsi() {
|
||||
return "\033[0m";
|
||||
}
|
||||
|
||||
TextStyle StyleParser::parseInlineStyle(const std::string& style) {
|
||||
TextStyle result;
|
||||
|
||||
// 简单的 CSS 解析
|
||||
std::istringstream iss(style);
|
||||
std::string declaration;
|
||||
|
||||
while (std::getline(iss, declaration, ';')) {
|
||||
size_t colon = declaration.find(':');
|
||||
if (colon == std::string::npos) continue;
|
||||
|
||||
std::string property = declaration.substr(0, colon);
|
||||
std::string value = declaration.substr(colon + 1);
|
||||
|
||||
// 去除空白
|
||||
property.erase(0, property.find_first_not_of(" \t"));
|
||||
property.erase(property.find_last_not_of(" \t") + 1);
|
||||
value.erase(0, value.find_first_not_of(" \t"));
|
||||
value.erase(value.find_last_not_of(" \t") + 1);
|
||||
|
||||
// 转换为小写
|
||||
std::transform(property.begin(), property.end(), property.begin(), ::tolower);
|
||||
std::transform(value.begin(), value.end(), value.begin(), ::tolower);
|
||||
|
||||
if (property == "color") {
|
||||
result.foreground = parseColor(value);
|
||||
} else if (property == "background-color" || property == "background") {
|
||||
result.background = parseColor(value);
|
||||
} else if (property == "font-weight") {
|
||||
result.bold = (value == "bold" || value == "700" || value == "800" || value == "900");
|
||||
} else if (property == "font-style") {
|
||||
result.italic = (value == "italic" || value == "oblique");
|
||||
} else if (property == "text-decoration") {
|
||||
result.underline = (value.find("underline") != std::string::npos);
|
||||
result.strikethrough = (value.find("line-through") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<Color> StyleParser::parseColor(const std::string& value) {
|
||||
if (value.empty()) return std::nullopt;
|
||||
|
||||
// 十六进制颜色
|
||||
if (value[0] == '#') {
|
||||
return Color::fromHex(value);
|
||||
}
|
||||
|
||||
// rgb() 格式
|
||||
if (value.substr(0, 4) == "rgb(") {
|
||||
size_t start = 4;
|
||||
size_t end = value.find(')');
|
||||
if (end == std::string::npos) return std::nullopt;
|
||||
|
||||
std::string values = value.substr(start, end - start);
|
||||
std::istringstream iss(values);
|
||||
std::string token;
|
||||
std::vector<int> components;
|
||||
|
||||
while (std::getline(iss, token, ',')) {
|
||||
try {
|
||||
components.push_back(std::stoi(token));
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
if (components.size() >= 3) {
|
||||
Color color;
|
||||
color.r = static_cast<uint8_t>(std::clamp(components[0], 0, 255));
|
||||
color.g = static_cast<uint8_t>(std::clamp(components[1], 0, 255));
|
||||
color.b = static_cast<uint8_t>(std::clamp(components[2], 0, 255));
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
// 命名颜色
|
||||
return getNamedColor(value);
|
||||
}
|
||||
|
||||
std::optional<Color> StyleParser::getNamedColor(const std::string& name) {
|
||||
auto it = named_colors_.find(name);
|
||||
if (it != named_colors_.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
106
src/renderer/style_parser.hpp
Normal file
106
src/renderer/style_parser.hpp
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* @file style_parser.hpp
|
||||
* @brief 样式解析模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 颜色结构体
|
||||
*/
|
||||
struct Color {
|
||||
uint8_t r{0};
|
||||
uint8_t g{0};
|
||||
uint8_t b{0};
|
||||
uint8_t a{255};
|
||||
|
||||
bool operator==(const Color& other) const {
|
||||
return r == other.r && g == other.g && b == other.b && a == other.a;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 从十六进制字符串解析
|
||||
* @param hex 十六进制颜色 (如 "#FF0000" 或 "FF0000")
|
||||
*/
|
||||
static std::optional<Color> fromHex(const std::string& hex);
|
||||
|
||||
/**
|
||||
* @brief 转换为十六进制字符串
|
||||
*/
|
||||
std::string toHex() const;
|
||||
|
||||
/**
|
||||
* @brief 转换为 ANSI 256 色
|
||||
*/
|
||||
int toAnsi256() const;
|
||||
|
||||
/**
|
||||
* @brief 转换为 ANSI 转义序列
|
||||
* @param foreground 是否是前景色
|
||||
*/
|
||||
std::string toAnsiEscape(bool foreground = true) const;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 文本样式
|
||||
*/
|
||||
struct TextStyle {
|
||||
std::optional<Color> foreground;
|
||||
std::optional<Color> background;
|
||||
bool bold{false};
|
||||
bool italic{false};
|
||||
bool underline{false};
|
||||
bool strikethrough{false};
|
||||
|
||||
/**
|
||||
* @brief 转换为 ANSI 转义序列
|
||||
*/
|
||||
std::string toAnsiEscape() const;
|
||||
|
||||
/**
|
||||
* @brief 重置 ANSI 格式
|
||||
*/
|
||||
static std::string resetAnsi();
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 样式解析器类
|
||||
*
|
||||
* 解析基础的 CSS 样式
|
||||
*/
|
||||
class StyleParser {
|
||||
public:
|
||||
/**
|
||||
* @brief 解析内联样式
|
||||
* @param style CSS 样式字符串
|
||||
* @return 解析后的样式
|
||||
*/
|
||||
static TextStyle parseInlineStyle(const std::string& style);
|
||||
|
||||
/**
|
||||
* @brief 解析颜色值
|
||||
* @param value CSS 颜色值
|
||||
* @return 解析后的颜色
|
||||
*/
|
||||
static std::optional<Color> parseColor(const std::string& value);
|
||||
|
||||
/**
|
||||
* @brief 获取命名颜色
|
||||
* @param name 颜色名称
|
||||
* @return 颜色值
|
||||
*/
|
||||
static std::optional<Color> getNamedColor(const std::string& name);
|
||||
|
||||
private:
|
||||
static const std::map<std::string, Color> named_colors_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
152
src/renderer/text_formatter.cpp
Normal file
152
src/renderer/text_formatter.cpp
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* @file text_formatter.cpp
|
||||
* @brief 文本格式化实现
|
||||
*/
|
||||
|
||||
#include "renderer/text_formatter.hpp"
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
|
||||
namespace tut {
|
||||
|
||||
std::vector<std::string> TextFormatter::wrapText(const std::string& text, int width) {
|
||||
std::vector<std::string> lines;
|
||||
if (width <= 0 || text.empty()) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::istringstream iss(text);
|
||||
std::string word;
|
||||
std::string current_line;
|
||||
|
||||
while (iss >> word) {
|
||||
if (current_line.empty()) {
|
||||
current_line = word;
|
||||
} else if (static_cast<int>(current_line.length() + 1 + word.length()) <= width) {
|
||||
current_line += " " + word;
|
||||
} else {
|
||||
lines.push_back(current_line);
|
||||
current_line = word;
|
||||
}
|
||||
}
|
||||
|
||||
if (!current_line.empty()) {
|
||||
lines.push_back(current_line);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::string TextFormatter::alignLeft(const std::string& text, int width) {
|
||||
if (static_cast<int>(text.length()) >= width) {
|
||||
return text;
|
||||
}
|
||||
return text + std::string(width - text.length(), ' ');
|
||||
}
|
||||
|
||||
std::string TextFormatter::alignRight(const std::string& text, int width) {
|
||||
if (static_cast<int>(text.length()) >= width) {
|
||||
return text;
|
||||
}
|
||||
return std::string(width - text.length(), ' ') + text;
|
||||
}
|
||||
|
||||
std::string TextFormatter::alignCenter(const std::string& text, int width) {
|
||||
if (static_cast<int>(text.length()) >= width) {
|
||||
return text;
|
||||
}
|
||||
int padding = width - static_cast<int>(text.length());
|
||||
int left_padding = padding / 2;
|
||||
int right_padding = padding - left_padding;
|
||||
return std::string(left_padding, ' ') + text + std::string(right_padding, ' ');
|
||||
}
|
||||
|
||||
std::string TextFormatter::truncate(const std::string& text, size_t max_length,
|
||||
const std::string& suffix) {
|
||||
if (text.length() <= max_length) {
|
||||
return text;
|
||||
}
|
||||
if (max_length <= suffix.length()) {
|
||||
return suffix.substr(0, max_length);
|
||||
}
|
||||
return text.substr(0, max_length - suffix.length()) + suffix;
|
||||
}
|
||||
|
||||
std::string TextFormatter::trim(const std::string& text) {
|
||||
size_t start = text.find_first_not_of(" \t\n\r\f\v");
|
||||
if (start == std::string::npos) {
|
||||
return "";
|
||||
}
|
||||
size_t end = text.find_last_not_of(" \t\n\r\f\v");
|
||||
return text.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
int TextFormatter::displayWidth(const std::string& text) {
|
||||
int width = 0;
|
||||
for (size_t i = 0; i < text.length(); ) {
|
||||
unsigned char c = text[i];
|
||||
if ((c & 0x80) == 0) {
|
||||
// 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 (CJK 字符通常是这种)
|
||||
width += 2; // 假设是宽字符
|
||||
i += 3;
|
||||
} else if ((c & 0xF8) == 0xF0) {
|
||||
// 4-byte UTF-8
|
||||
width += 2;
|
||||
i += 4;
|
||||
} else {
|
||||
width += 1;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
std::string TextFormatter::expandTabs(const std::string& text, int tab_size) {
|
||||
std::string result;
|
||||
int column = 0;
|
||||
|
||||
for (char c : text) {
|
||||
if (c == '\t') {
|
||||
int spaces = tab_size - (column % tab_size);
|
||||
result.append(spaces, ' ');
|
||||
column += spaces;
|
||||
} else if (c == '\n') {
|
||||
result += c;
|
||||
column = 0;
|
||||
} else {
|
||||
result += c;
|
||||
column++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string TextFormatter::normalizeWhitespace(const std::string& text) {
|
||||
std::string result;
|
||||
bool last_was_space = false;
|
||||
|
||||
for (char c : text) {
|
||||
if (std::isspace(static_cast<unsigned char>(c))) {
|
||||
if (!last_was_space) {
|
||||
result += ' ';
|
||||
last_was_space = true;
|
||||
}
|
||||
} else {
|
||||
result += c;
|
||||
last_was_space = false;
|
||||
}
|
||||
}
|
||||
|
||||
return trim(result);
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
94
src/renderer/text_formatter.hpp
Normal file
94
src/renderer/text_formatter.hpp
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* @file text_formatter.hpp
|
||||
* @brief 文本格式化模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 文本格式化器类
|
||||
*
|
||||
* 负责文本的格式化、换行、对齐等操作
|
||||
*/
|
||||
class TextFormatter {
|
||||
public:
|
||||
/**
|
||||
* @brief 自动换行
|
||||
* @param text 输入文本
|
||||
* @param width 每行最大宽度
|
||||
* @return 换行后的文本行
|
||||
*/
|
||||
static std::vector<std::string> wrapText(const std::string& text, int width);
|
||||
|
||||
/**
|
||||
* @brief 左对齐
|
||||
* @param text 输入文本
|
||||
* @param width 目标宽度
|
||||
* @return 左对齐后的文本
|
||||
*/
|
||||
static std::string alignLeft(const std::string& text, int width);
|
||||
|
||||
/**
|
||||
* @brief 右对齐
|
||||
* @param text 输入文本
|
||||
* @param width 目标宽度
|
||||
* @return 右对齐后的文本
|
||||
*/
|
||||
static std::string alignRight(const std::string& text, int width);
|
||||
|
||||
/**
|
||||
* @brief 居中对齐
|
||||
* @param text 输入文本
|
||||
* @param width 目标宽度
|
||||
* @return 居中对齐后的文本
|
||||
*/
|
||||
static std::string alignCenter(const std::string& text, int width);
|
||||
|
||||
/**
|
||||
* @brief 截断文本
|
||||
* @param text 输入文本
|
||||
* @param max_length 最大长度
|
||||
* @param suffix 截断后缀 (默认 "...")
|
||||
* @return 截断后的文本
|
||||
*/
|
||||
static std::string truncate(const std::string& text, size_t max_length,
|
||||
const std::string& suffix = "...");
|
||||
|
||||
/**
|
||||
* @brief 去除首尾空白
|
||||
* @param text 输入文本
|
||||
* @return 处理后的文本
|
||||
*/
|
||||
static std::string trim(const std::string& text);
|
||||
|
||||
/**
|
||||
* @brief 计算显示宽度 (考虑 Unicode 字符)
|
||||
* @param text 输入文本
|
||||
* @return 显示宽度
|
||||
*/
|
||||
static int displayWidth(const std::string& text);
|
||||
|
||||
/**
|
||||
* @brief 将制表符转换为空格
|
||||
* @param text 输入文本
|
||||
* @param tab_size 制表符大小
|
||||
* @return 转换后的文本
|
||||
*/
|
||||
static std::string expandTabs(const std::string& text, int tab_size = 4);
|
||||
|
||||
/**
|
||||
* @brief 规范化空白字符
|
||||
* @param text 输入文本
|
||||
* @return 规范化后的文本
|
||||
*/
|
||||
static std::string normalizeWhitespace(const std::string& text);
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
|
|
@ -1,641 +0,0 @@
|
|||
#include "text_renderer.h"
|
||||
#include "dom_tree.h"
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <cstring>
|
||||
#include <cwchar>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
namespace {
|
||||
// Calculate display width of UTF-8 string (handling CJK characters)
|
||||
size_t display_width(const std::string& str) {
|
||||
size_t width = 0;
|
||||
for (size_t i = 0; i < str.length(); ) {
|
||||
unsigned char c = str[i];
|
||||
|
||||
if (c < 0x80) {
|
||||
// ASCII
|
||||
width += 1;
|
||||
i += 1;
|
||||
} else if ((c & 0xE0) == 0xC0) {
|
||||
// 2-byte UTF-8
|
||||
width += 1;
|
||||
i += 2;
|
||||
} else if ((c & 0xF0) == 0xE0) {
|
||||
// 3-byte UTF-8 (likely CJK)
|
||||
width += 2;
|
||||
i += 3;
|
||||
} else if ((c & 0xF8) == 0xF0) {
|
||||
// 4-byte UTF-8
|
||||
width += 2;
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
// Pad string to specific visual width
|
||||
std::string pad_string(const std::string& str, size_t target_width) {
|
||||
size_t current_width = display_width(str);
|
||||
if (current_width >= target_width) return str;
|
||||
return str + std::string(target_width - current_width, ' ');
|
||||
}
|
||||
|
||||
// Clean whitespace
|
||||
std::string clean_text(const std::string& text) {
|
||||
std::string result;
|
||||
bool in_space = false;
|
||||
|
||||
for (char c : text) {
|
||||
if (std::isspace(c)) {
|
||||
if (!in_space) {
|
||||
result += ' ';
|
||||
in_space = true;
|
||||
}
|
||||
} else {
|
||||
result += c;
|
||||
in_space = false;
|
||||
}
|
||||
}
|
||||
|
||||
size_t start = result.find_first_not_of(" \t\n\r");
|
||||
if (start == std::string::npos) return "";
|
||||
|
||||
size_t end = result.find_last_not_of(" \t\n\r");
|
||||
return result.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
struct LinkInfo {
|
||||
size_t start_pos;
|
||||
size_t end_pos;
|
||||
int link_index;
|
||||
int field_index;
|
||||
};
|
||||
|
||||
// Text wrapping with link preservation
|
||||
std::vector<std::pair<std::string, std::vector<LinkInfo>>> wrap_text_with_links(
|
||||
const std::string& text,
|
||||
int max_width,
|
||||
const std::vector<InlineLink>& links
|
||||
) {
|
||||
std::vector<std::pair<std::string, std::vector<LinkInfo>>> result;
|
||||
if (max_width <= 0) return result;
|
||||
|
||||
// 1. Insert [N] markers for links (form fields don't get [N])
|
||||
std::string marked_text;
|
||||
std::vector<LinkInfo> adjusted_links;
|
||||
size_t pos = 0;
|
||||
|
||||
for (const auto& link : links) {
|
||||
marked_text += text.substr(pos, link.start_pos - pos);
|
||||
size_t link_start = marked_text.length();
|
||||
|
||||
marked_text += text.substr(link.start_pos, link.end_pos - link.start_pos);
|
||||
|
||||
// Add marker [N] only for links
|
||||
if (link.link_index >= 0) {
|
||||
std::string marker = "[" + std::to_string(link.link_index + 1) + "]";
|
||||
marked_text += marker;
|
||||
}
|
||||
|
||||
size_t link_end = marked_text.length();
|
||||
|
||||
adjusted_links.push_back({link_start, link_end, link.link_index, link.field_index});
|
||||
pos = link.end_pos;
|
||||
}
|
||||
|
||||
if (pos < text.length()) {
|
||||
marked_text += text.substr(pos);
|
||||
}
|
||||
|
||||
// 2. Wrap text
|
||||
size_t line_start_idx = 0;
|
||||
size_t current_line_width = 0;
|
||||
size_t last_space_idx = std::string::npos;
|
||||
|
||||
for (size_t i = 0; i <= marked_text.length(); ++i) {
|
||||
bool is_break = (i == marked_text.length() || marked_text[i] == ' ' || marked_text[i] == '\n');
|
||||
|
||||
if (is_break) {
|
||||
std::string word = marked_text.substr(
|
||||
(last_space_idx == std::string::npos) ? line_start_idx : last_space_idx + 1,
|
||||
i - ((last_space_idx == std::string::npos) ? line_start_idx : last_space_idx + 1)
|
||||
);
|
||||
|
||||
size_t word_width = display_width(word);
|
||||
size_t space_width = (current_line_width == 0) ? 0 : 1;
|
||||
|
||||
if (current_line_width + space_width + word_width > static_cast<size_t>(max_width)) {
|
||||
// Wrap
|
||||
if (current_line_width > 0) {
|
||||
// End current line at last space
|
||||
std::string line_str = marked_text.substr(line_start_idx, last_space_idx - line_start_idx);
|
||||
|
||||
// Collect links
|
||||
std::vector<LinkInfo> line_links;
|
||||
for (const auto& link : adjusted_links) {
|
||||
// Check overlap
|
||||
size_t link_s = link.start_pos;
|
||||
size_t link_e = link.end_pos;
|
||||
size_t line_s = line_start_idx;
|
||||
size_t line_e = last_space_idx;
|
||||
|
||||
if (link_s < line_e && link_e > line_s) {
|
||||
size_t start = (link_s > line_s) ? link_s - line_s : 0;
|
||||
size_t end = (link_e < line_e) ? link_e - line_s : line_e - line_s;
|
||||
line_links.push_back({start, end, link.link_index, link.field_index});
|
||||
}
|
||||
}
|
||||
result.push_back({line_str, line_links});
|
||||
|
||||
// Start new line
|
||||
line_start_idx = last_space_idx + 1;
|
||||
current_line_width = word_width;
|
||||
last_space_idx = i;
|
||||
} else {
|
||||
// Word itself is too long, force break (not implemented for simplicity, just overflow)
|
||||
last_space_idx = i;
|
||||
current_line_width += space_width + word_width;
|
||||
}
|
||||
} else {
|
||||
current_line_width += space_width + word_width;
|
||||
last_space_idx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last line
|
||||
if (line_start_idx < marked_text.length()) {
|
||||
std::string line_str = marked_text.substr(line_start_idx);
|
||||
std::vector<LinkInfo> line_links;
|
||||
for (const auto& link : adjusted_links) {
|
||||
size_t link_s = link.start_pos;
|
||||
size_t link_e = link.end_pos;
|
||||
size_t line_s = line_start_idx;
|
||||
size_t line_e = marked_text.length();
|
||||
|
||||
if (link_s < line_e && link_e > line_s) {
|
||||
size_t start = (link_s > line_s) ? link_s - line_s : 0;
|
||||
size_t end = (link_e < line_e) ? link_e - line_s : line_e - line_s;
|
||||
line_links.push_back({start, end, link.link_index, link.field_index});
|
||||
}
|
||||
}
|
||||
result.push_back({line_str, line_links});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TextRenderer::Impl
|
||||
// ============================================================================
|
||||
|
||||
class TextRenderer::Impl {
|
||||
public:
|
||||
RenderConfig config;
|
||||
|
||||
struct InlineContent {
|
||||
std::string text;
|
||||
std::vector<InlineLink> links;
|
||||
};
|
||||
|
||||
RenderedLine create_empty_line() {
|
||||
RenderedLine line;
|
||||
line.text = "";
|
||||
line.color_pair = COLOR_NORMAL;
|
||||
line.is_bold = false;
|
||||
line.is_link = false;
|
||||
line.link_index = -1;
|
||||
return line;
|
||||
}
|
||||
|
||||
std::vector<RenderedLine> render_tree(const DocumentTree& tree, int screen_width) {
|
||||
std::vector<RenderedLine> lines;
|
||||
if (!tree.root) return lines;
|
||||
|
||||
RenderContext ctx;
|
||||
ctx.screen_width = config.center_content ? std::min(config.max_width, screen_width) : screen_width;
|
||||
ctx.current_indent = 0;
|
||||
ctx.nesting_level = 0;
|
||||
ctx.color_pair = COLOR_NORMAL;
|
||||
ctx.is_bold = false;
|
||||
|
||||
render_node(tree.root.get(), ctx, lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
void render_node(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
if (!node || !node->should_render()) return;
|
||||
|
||||
if (node->is_block_element()) {
|
||||
if (node->tag_name == "table") {
|
||||
render_table(node, ctx, lines);
|
||||
} else {
|
||||
switch (node->element_type) {
|
||||
case ElementType::HEADING1:
|
||||
case ElementType::HEADING2:
|
||||
case ElementType::HEADING3:
|
||||
render_heading(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::PARAGRAPH:
|
||||
render_paragraph(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::HORIZONTAL_RULE:
|
||||
render_hr(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::CODE_BLOCK:
|
||||
render_code_block(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::BLOCKQUOTE:
|
||||
render_blockquote(node, ctx, lines);
|
||||
break;
|
||||
default:
|
||||
if (node->tag_name == "ul" || node->tag_name == "ol") {
|
||||
render_list(node, ctx, lines);
|
||||
} else {
|
||||
for (auto& child : node->children) {
|
||||
render_node(child.get(), ctx, lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node->node_type == NodeType::DOCUMENT || node->node_type == NodeType::ELEMENT) {
|
||||
for (auto& child : node->children) {
|
||||
render_node(child.get(), ctx, lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Table Rendering
|
||||
// ========================================================================
|
||||
|
||||
struct CellData {
|
||||
std::vector<std::string> lines; // Wrapped lines
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int colspan = 1;
|
||||
int rowspan = 1;
|
||||
bool is_header = false;
|
||||
};
|
||||
|
||||
void render_table(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
// Simplified table rendering (skipping complex grid for brevity, reverting to previous improved logic)
|
||||
// Note: For brevity in this tool call, reusing the logic from previous step but integrated with form fields?
|
||||
// Actually, let's keep the logic I wrote before.
|
||||
|
||||
// 1. Collect Table Data
|
||||
std::vector<std::vector<CellData>> grid;
|
||||
std::vector<int> col_widths;
|
||||
int max_cols = 0;
|
||||
|
||||
for (auto& child : node->children) {
|
||||
if (child->tag_name == "tr") {
|
||||
std::vector<CellData> row;
|
||||
for (auto& cell : child->children) {
|
||||
if (cell->tag_name == "td" || cell->tag_name == "th") {
|
||||
CellData data;
|
||||
data.is_header = (cell->tag_name == "th");
|
||||
data.colspan = cell->colspan > 0 ? cell->colspan : 1;
|
||||
InlineContent content = collect_inline_content(cell.get());
|
||||
std::string clean = clean_text(content.text);
|
||||
data.lines.push_back(clean);
|
||||
data.width = display_width(clean);
|
||||
data.height = 1;
|
||||
row.push_back(data);
|
||||
}
|
||||
}
|
||||
if (!row.empty()) {
|
||||
grid.push_back(row);
|
||||
max_cols = std::max(max_cols, (int)row.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (grid.empty()) return;
|
||||
|
||||
col_widths.assign(max_cols, 0);
|
||||
for (const auto& row : grid) {
|
||||
for (size_t i = 0; i < row.size(); ++i) {
|
||||
if (i < col_widths.size()) {
|
||||
col_widths[i] = std::max(col_widths[i], row[i].width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int total_width = std::accumulate(col_widths.begin(), col_widths.end(), 0);
|
||||
int available_width = ctx.screen_width - 4;
|
||||
available_width = std::max(10, available_width);
|
||||
|
||||
if (total_width > available_width) {
|
||||
double ratio = (double)available_width / total_width;
|
||||
for (auto& w : col_widths) {
|
||||
w = std::max(3, (int)(w * ratio));
|
||||
}
|
||||
}
|
||||
|
||||
std::string border_line = "+";
|
||||
for (int w : col_widths) {
|
||||
border_line += std::string(w + 2, '-') + "+";
|
||||
}
|
||||
|
||||
RenderedLine border;
|
||||
border.text = border_line;
|
||||
border.color_pair = COLOR_DIM;
|
||||
lines.push_back(border);
|
||||
|
||||
for (auto& row : grid) {
|
||||
int max_row_height = 0;
|
||||
std::vector<std::vector<std::string>> row_wrapped_content;
|
||||
|
||||
for (size_t i = 0; i < row.size(); ++i) {
|
||||
if (i >= col_widths.size()) break;
|
||||
|
||||
int cell_w = col_widths[i];
|
||||
std::string raw_text = row[i].lines[0];
|
||||
auto wrapped = wrap_text_with_links(raw_text, cell_w, {}); // Simplified: no links in table for now
|
||||
|
||||
std::vector<std::string> cell_lines;
|
||||
for(auto& p : wrapped) cell_lines.push_back(p.first);
|
||||
if (cell_lines.empty()) cell_lines.push_back("");
|
||||
|
||||
row_wrapped_content.push_back(cell_lines);
|
||||
max_row_height = std::max(max_row_height, (int)cell_lines.size());
|
||||
}
|
||||
|
||||
for (int h = 0; h < max_row_height; ++h) {
|
||||
std::string line_str = "|";
|
||||
for (size_t i = 0; i < col_widths.size(); ++i) {
|
||||
int w = col_widths[i];
|
||||
std::string content = "";
|
||||
if (i < row_wrapped_content.size() && h < (int)row_wrapped_content[i].size()) {
|
||||
content = row_wrapped_content[i][h];
|
||||
}
|
||||
line_str += " " + pad_string(content, w) + " |";
|
||||
}
|
||||
|
||||
RenderedLine rline;
|
||||
rline.text = line_str;
|
||||
rline.color_pair = COLOR_NORMAL;
|
||||
lines.push_back(rline);
|
||||
}
|
||||
|
||||
lines.push_back(border);
|
||||
}
|
||||
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Other Elements
|
||||
// ========================================================================
|
||||
|
||||
void render_heading(DomNode* node, RenderContext& /*ctx*/, std::vector<RenderedLine>& lines) {
|
||||
InlineContent content = collect_inline_content(node);
|
||||
if (content.text.empty()) return;
|
||||
|
||||
RenderedLine line;
|
||||
line.text = clean_text(content.text);
|
||||
line.color_pair = COLOR_HEADING1;
|
||||
line.is_bold = true;
|
||||
lines.push_back(line);
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_paragraph(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
InlineContent content = collect_inline_content(node);
|
||||
std::string text = clean_text(content.text);
|
||||
if (text.empty()) return;
|
||||
|
||||
auto wrapped = wrap_text_with_links(text, ctx.screen_width, content.links);
|
||||
for (const auto& [line_text, link_infos] : wrapped) {
|
||||
RenderedLine line;
|
||||
line.text = line_text;
|
||||
line.color_pair = COLOR_NORMAL;
|
||||
if (!link_infos.empty()) {
|
||||
line.is_link = true; // Kept for compatibility, though we use interactive_ranges
|
||||
line.link_index = -1;
|
||||
|
||||
for (const auto& li : link_infos) {
|
||||
InteractiveRange range;
|
||||
range.start = li.start_pos;
|
||||
range.end = li.end_pos;
|
||||
range.link_index = li.link_index;
|
||||
range.field_index = li.field_index;
|
||||
line.interactive_ranges.push_back(range);
|
||||
|
||||
if (li.link_index >= 0) line.link_index = li.link_index; // Heuristic: set main link index to first link
|
||||
}
|
||||
}
|
||||
lines.push_back(line);
|
||||
}
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_list(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
bool is_ordered = (node->tag_name == "ol");
|
||||
int count = 1;
|
||||
|
||||
for(auto& child : node->children) {
|
||||
if(child->tag_name == "li") {
|
||||
InlineContent content = collect_inline_content(child.get());
|
||||
std::string prefix = is_ordered ? std::to_string(count++) + ". " : "* ";
|
||||
|
||||
auto wrapped = wrap_text_with_links(clean_text(content.text), ctx.screen_width - 4, content.links);
|
||||
|
||||
bool first = true;
|
||||
for(const auto& [txt, links_info] : wrapped) {
|
||||
RenderedLine line;
|
||||
line.text = (first ? prefix : " ") + txt;
|
||||
line.color_pair = COLOR_NORMAL;
|
||||
|
||||
if(!links_info.empty()) {
|
||||
line.is_link = true;
|
||||
for(const auto& l : links_info) {
|
||||
InteractiveRange range;
|
||||
range.start = l.start_pos + prefix.length();
|
||||
range.end = l.end_pos + prefix.length();
|
||||
range.link_index = l.link_index;
|
||||
range.field_index = l.field_index;
|
||||
line.interactive_ranges.push_back(range);
|
||||
}
|
||||
}
|
||||
lines.push_back(line);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_hr(DomNode* /*node*/, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
RenderedLine line;
|
||||
line.text = std::string(ctx.screen_width, '-');
|
||||
line.color_pair = COLOR_DIM;
|
||||
lines.push_back(line);
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_code_block(DomNode* node, RenderContext& /*ctx*/, std::vector<RenderedLine>& lines) {
|
||||
std::string text = node->get_all_text();
|
||||
std::istringstream iss(text);
|
||||
std::string line_str;
|
||||
while(std::getline(iss, line_str)) {
|
||||
RenderedLine line;
|
||||
line.text = " " + line_str;
|
||||
line.color_pair = COLOR_DIM;
|
||||
lines.push_back(line);
|
||||
}
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_blockquote(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
for (auto& child : node->children) {
|
||||
render_node(child.get(), ctx, lines);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Collect Inline Content
|
||||
InlineContent collect_inline_content(DomNode* node) {
|
||||
InlineContent result;
|
||||
for (auto& child : node->children) {
|
||||
if (child->node_type == NodeType::TEXT) {
|
||||
result.text += child->text_content;
|
||||
} else if (child->element_type == ElementType::LINK && child->link_index >= 0) {
|
||||
InlineLink link;
|
||||
link.text = child->get_all_text();
|
||||
link.url = child->href;
|
||||
link.link_index = child->link_index;
|
||||
link.field_index = -1;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += link.text;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::INPUT) {
|
||||
std::string repr;
|
||||
if (child->input_type == "checkbox") {
|
||||
repr = child->checked ? "[x]" : "[ ]";
|
||||
} else if (child->input_type == "radio") {
|
||||
repr = child->checked ? "(*)" : "( )";
|
||||
} else if (child->input_type == "submit" || child->input_type == "button") {
|
||||
repr = "[" + (child->value.empty() ? "Submit" : child->value) + "]";
|
||||
} else {
|
||||
// text, password, etc.
|
||||
std::string val = child->value.empty() ? child->placeholder : child->value;
|
||||
if (val.empty()) val = "________";
|
||||
repr = "[" + val + "]";
|
||||
}
|
||||
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::BUTTON) {
|
||||
std::string repr = "[" + (child->value.empty() ? (child->name.empty() ? "Button" : child->name) : child->value) + "]";
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::TEXTAREA) {
|
||||
std::string repr = "[ " + (child->value.empty() ? "Textarea" : child->value) + " ]";
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::SELECT) {
|
||||
std::string repr = "[ Select ]"; // Simplified
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::IMAGE) {
|
||||
// Render image placeholder
|
||||
std::string repr = "[IMG";
|
||||
if (!child->alt_text.empty()) {
|
||||
repr += ": " + child->alt_text;
|
||||
}
|
||||
repr += "]";
|
||||
|
||||
result.text += repr;
|
||||
// Images are not necessarily links unless wrapped in <a>.
|
||||
// If wrapped in <a>, the parent processing handles the link range.
|
||||
} else {
|
||||
InlineContent nested = collect_inline_content(child.get());
|
||||
size_t offset = result.text.length();
|
||||
result.text += nested.text;
|
||||
for(auto l : nested.links) {
|
||||
l.start_pos += offset;
|
||||
l.end_pos += offset;
|
||||
result.links.push_back(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Legacy support
|
||||
std::vector<RenderedLine> render_legacy(const ParsedDocument& /*doc*/, int /*screen_width*/) {
|
||||
return {}; // Not used anymore
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Public Interface
|
||||
// ============================================================================
|
||||
|
||||
TextRenderer::TextRenderer() : pImpl(std::make_unique<Impl>()) {}
|
||||
TextRenderer::~TextRenderer() = default;
|
||||
|
||||
std::vector<RenderedLine> TextRenderer::render_tree(const DocumentTree& tree, int screen_width) {
|
||||
return pImpl->render_tree(tree, screen_width);
|
||||
}
|
||||
|
||||
std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int screen_width) {
|
||||
return pImpl->render_legacy(doc, screen_width);
|
||||
}
|
||||
|
||||
void TextRenderer::set_config(const RenderConfig& config) {
|
||||
pImpl->config = config;
|
||||
}
|
||||
|
||||
RenderConfig TextRenderer::get_config() const {
|
||||
return pImpl->config;
|
||||
}
|
||||
|
||||
void init_color_scheme() {
|
||||
init_pair(COLOR_NORMAL, COLOR_WHITE, COLOR_BLACK);
|
||||
init_pair(COLOR_HEADING1, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_HEADING2, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_HEADING3, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_LINK, COLOR_YELLOW, COLOR_BLACK);
|
||||
init_pair(COLOR_LINK_ACTIVE, COLOR_YELLOW, COLOR_BLUE);
|
||||
init_pair(COLOR_STATUS_BAR, COLOR_BLACK, COLOR_WHITE);
|
||||
init_pair(COLOR_URL_BAR, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_SEARCH_HIGHLIGHT, COLOR_BLACK, COLOR_YELLOW);
|
||||
init_pair(COLOR_DIM, COLOR_WHITE, COLOR_BLACK);
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "html_parser.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <curses.h>
|
||||
|
||||
// Forward declarations
|
||||
struct DocumentTree;
|
||||
struct DomNode;
|
||||
|
||||
struct InteractiveRange {
|
||||
size_t start;
|
||||
size_t end;
|
||||
int link_index = -1;
|
||||
int field_index = -1;
|
||||
};
|
||||
|
||||
struct RenderedLine {
|
||||
std::string text;
|
||||
int color_pair;
|
||||
bool is_bold;
|
||||
bool is_link;
|
||||
int link_index;
|
||||
std::vector<InteractiveRange> interactive_ranges;
|
||||
};
|
||||
|
||||
// Unicode装饰字符
|
||||
namespace UnicodeChars {
|
||||
// 框线字符 (Box Drawing)
|
||||
constexpr const char* DBL_HORIZONTAL = "═";
|
||||
constexpr const char* DBL_VERTICAL = "║";
|
||||
constexpr const char* DBL_TOP_LEFT = "╔";
|
||||
constexpr const char* DBL_TOP_RIGHT = "╗";
|
||||
constexpr const char* DBL_BOTTOM_LEFT = "╚";
|
||||
constexpr const char* DBL_BOTTOM_RIGHT = "╝";
|
||||
|
||||
constexpr const char* SGL_HORIZONTAL = "─";
|
||||
constexpr const char* SGL_VERTICAL = "│";
|
||||
constexpr const char* SGL_TOP_LEFT = "┌";
|
||||
constexpr const char* SGL_TOP_RIGHT = "┐";
|
||||
constexpr const char* SGL_BOTTOM_LEFT = "└";
|
||||
constexpr const char* SGL_BOTTOM_RIGHT = "┘";
|
||||
constexpr const char* SGL_CROSS = "┼";
|
||||
constexpr const char* SGL_T_DOWN = "┬";
|
||||
constexpr const char* SGL_T_UP = "┴";
|
||||
constexpr const char* SGL_T_RIGHT = "├";
|
||||
constexpr const char* SGL_T_LEFT = "┤";
|
||||
|
||||
constexpr const char* HEAVY_HORIZONTAL = "━";
|
||||
constexpr const char* HEAVY_VERTICAL = "┃";
|
||||
|
||||
// 列表符号
|
||||
constexpr const char* BULLET = "•";
|
||||
constexpr const char* CIRCLE = "◦";
|
||||
constexpr const char* SQUARE = "▪";
|
||||
constexpr const char* TRIANGLE = "‣";
|
||||
|
||||
// 装饰符号
|
||||
constexpr const char* SECTION = "§";
|
||||
constexpr const char* PARAGRAPH = "¶";
|
||||
constexpr const char* ARROW_RIGHT = "→";
|
||||
constexpr const char* ELLIPSIS = "…";
|
||||
}
|
||||
|
||||
struct RenderConfig {
|
||||
// 布局设置
|
||||
int max_width = 80; // 最大内容宽度
|
||||
int margin_left = 0; // 左边距
|
||||
bool center_content = false; // 内容居中
|
||||
int paragraph_spacing = 1; // 段落间距
|
||||
|
||||
// 响应式宽度设置
|
||||
bool responsive_width = true; // 启用响应式宽度
|
||||
int min_width = 60; // 最小内容宽度
|
||||
int max_content_width = 100; // 最大内容宽度
|
||||
int small_screen_threshold = 80; // 小屏阈值
|
||||
int large_screen_threshold = 120;// 大屏阈值
|
||||
|
||||
// 链接设置
|
||||
bool show_link_indicators = false; // 不显示[N]编号
|
||||
bool inline_links = true; // 内联链接(仅颜色)
|
||||
|
||||
// 视觉样式
|
||||
bool use_unicode_boxes = true; // 使用Unicode框线
|
||||
bool use_fancy_bullets = true; // 使用精美列表符号
|
||||
bool show_decorative_lines = true; // 显示装饰线
|
||||
|
||||
// 标题样式
|
||||
bool h1_use_double_border = true; // H1使用双线框
|
||||
bool h2_use_single_border = true; // H2使用单线框
|
||||
bool h3_use_underline = true; // H3使用下划线
|
||||
};
|
||||
|
||||
// 渲染上下文
|
||||
struct RenderContext {
|
||||
int screen_width; // 终端宽度
|
||||
int current_indent; // 当前缩进级别
|
||||
int nesting_level; // 列表嵌套层级
|
||||
int color_pair; // 当前颜色
|
||||
bool is_bold; // 是否加粗
|
||||
};
|
||||
|
||||
class TextRenderer {
|
||||
public:
|
||||
TextRenderer();
|
||||
~TextRenderer();
|
||||
|
||||
// 新接口:从DOM树渲染
|
||||
std::vector<RenderedLine> render_tree(const DocumentTree& tree, int screen_width);
|
||||
|
||||
// 旧接口:向后兼容
|
||||
std::vector<RenderedLine> render(const ParsedDocument& doc, int screen_width);
|
||||
|
||||
void set_config(const RenderConfig& config);
|
||||
RenderConfig get_config() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
||||
enum ColorScheme {
|
||||
COLOR_NORMAL = 1,
|
||||
COLOR_HEADING1,
|
||||
COLOR_HEADING2,
|
||||
COLOR_HEADING3,
|
||||
COLOR_LINK,
|
||||
COLOR_LINK_ACTIVE,
|
||||
COLOR_STATUS_BAR,
|
||||
COLOR_URL_BAR,
|
||||
COLOR_SEARCH_HIGHLIGHT,
|
||||
COLOR_DIM
|
||||
};
|
||||
|
||||
void init_color_scheme();
|
||||
50
src/ui/address_bar.cpp
Normal file
50
src/ui/address_bar.cpp
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* @file address_bar.cpp
|
||||
* @brief 地址栏组件实现
|
||||
*/
|
||||
|
||||
#include "ui/address_bar.hpp"
|
||||
|
||||
namespace tut {
|
||||
|
||||
class AddressBar::Impl {
|
||||
public:
|
||||
std::string url_;
|
||||
std::vector<std::string> history_;
|
||||
std::function<void(const std::string&)> on_submit_;
|
||||
bool focused_{false};
|
||||
};
|
||||
|
||||
AddressBar::AddressBar() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
AddressBar::~AddressBar() = default;
|
||||
|
||||
void AddressBar::setUrl(const std::string& url) {
|
||||
impl_->url_ = url;
|
||||
}
|
||||
|
||||
std::string AddressBar::getUrl() const {
|
||||
return impl_->url_;
|
||||
}
|
||||
|
||||
void AddressBar::setHistory(const std::vector<std::string>& history) {
|
||||
impl_->history_ = history;
|
||||
}
|
||||
|
||||
void AddressBar::onSubmit(std::function<void(const std::string&)> callback) {
|
||||
impl_->on_submit_ = std::move(callback);
|
||||
}
|
||||
|
||||
void AddressBar::focus() {
|
||||
impl_->focused_ = true;
|
||||
}
|
||||
|
||||
void AddressBar::blur() {
|
||||
impl_->focused_ = false;
|
||||
}
|
||||
|
||||
bool AddressBar::isFocused() const {
|
||||
return impl_->focused_;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
65
src/ui/address_bar.hpp
Normal file
65
src/ui/address_bar.hpp
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* @file address_bar.hpp
|
||||
* @brief 地址栏组件
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 地址栏组件类
|
||||
*/
|
||||
class AddressBar {
|
||||
public:
|
||||
AddressBar();
|
||||
~AddressBar();
|
||||
|
||||
/**
|
||||
* @brief 设置当前 URL
|
||||
*/
|
||||
void setUrl(const std::string& url);
|
||||
|
||||
/**
|
||||
* @brief 获取当前 URL
|
||||
*/
|
||||
std::string getUrl() const;
|
||||
|
||||
/**
|
||||
* @brief 设置历史记录 (用于自动补全)
|
||||
*/
|
||||
void setHistory(const std::vector<std::string>& history);
|
||||
|
||||
/**
|
||||
* @brief 注册 URL 提交回调
|
||||
*/
|
||||
void onSubmit(std::function<void(const std::string&)> callback);
|
||||
|
||||
/**
|
||||
* @brief 聚焦地址栏
|
||||
*/
|
||||
void focus();
|
||||
|
||||
/**
|
||||
* @brief 取消聚焦
|
||||
*/
|
||||
void blur();
|
||||
|
||||
/**
|
||||
* @brief 是否处于聚焦状态
|
||||
*/
|
||||
bool isFocused() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
93
src/ui/bookmark_panel.cpp
Normal file
93
src/ui/bookmark_panel.cpp
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* @file bookmark_panel.cpp
|
||||
* @brief 书签面板组件实现
|
||||
*/
|
||||
|
||||
#include "ui/bookmark_panel.hpp"
|
||||
#include <algorithm>
|
||||
|
||||
namespace tut {
|
||||
|
||||
class BookmarkPanel::Impl {
|
||||
public:
|
||||
std::vector<BookmarkItem> bookmarks_;
|
||||
int selected_index_{0};
|
||||
bool visible_{false};
|
||||
std::function<void(const BookmarkItem&)> on_select_;
|
||||
};
|
||||
|
||||
BookmarkPanel::BookmarkPanel() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
BookmarkPanel::~BookmarkPanel() = default;
|
||||
|
||||
void BookmarkPanel::setBookmarks(const std::vector<BookmarkItem>& bookmarks) {
|
||||
impl_->bookmarks_ = bookmarks;
|
||||
impl_->selected_index_ = bookmarks.empty() ? -1 : 0;
|
||||
}
|
||||
|
||||
std::vector<BookmarkItem> BookmarkPanel::getBookmarks() const {
|
||||
return impl_->bookmarks_;
|
||||
}
|
||||
|
||||
void BookmarkPanel::addBookmark(const BookmarkItem& bookmark) {
|
||||
impl_->bookmarks_.push_back(bookmark);
|
||||
}
|
||||
|
||||
void BookmarkPanel::removeBookmark(const std::string& id) {
|
||||
impl_->bookmarks_.erase(
|
||||
std::remove_if(impl_->bookmarks_.begin(), impl_->bookmarks_.end(),
|
||||
[&id](const BookmarkItem& item) { return item.id == id; }),
|
||||
impl_->bookmarks_.end());
|
||||
}
|
||||
|
||||
void BookmarkPanel::selectNext() {
|
||||
if (impl_->bookmarks_.empty()) return;
|
||||
impl_->selected_index_ =
|
||||
(impl_->selected_index_ + 1) % static_cast<int>(impl_->bookmarks_.size());
|
||||
}
|
||||
|
||||
void BookmarkPanel::selectPrevious() {
|
||||
if (impl_->bookmarks_.empty()) return;
|
||||
impl_->selected_index_--;
|
||||
if (impl_->selected_index_ < 0) {
|
||||
impl_->selected_index_ = static_cast<int>(impl_->bookmarks_.size()) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
int BookmarkPanel::getSelectedIndex() const {
|
||||
return impl_->selected_index_;
|
||||
}
|
||||
|
||||
void BookmarkPanel::onSelect(std::function<void(const BookmarkItem&)> callback) {
|
||||
impl_->on_select_ = std::move(callback);
|
||||
}
|
||||
|
||||
std::vector<BookmarkItem> BookmarkPanel::search(const std::string& query) const {
|
||||
std::vector<BookmarkItem> results;
|
||||
std::string lower_query = query;
|
||||
std::transform(lower_query.begin(), lower_query.end(), lower_query.begin(), ::tolower);
|
||||
|
||||
for (const auto& bookmark : impl_->bookmarks_) {
|
||||
std::string lower_title = bookmark.title;
|
||||
std::transform(lower_title.begin(), lower_title.end(), lower_title.begin(), ::tolower);
|
||||
|
||||
std::string lower_url = bookmark.url;
|
||||
std::transform(lower_url.begin(), lower_url.end(), lower_url.begin(), ::tolower);
|
||||
|
||||
if (lower_title.find(lower_query) != std::string::npos ||
|
||||
lower_url.find(lower_query) != std::string::npos) {
|
||||
results.push_back(bookmark);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
void BookmarkPanel::setVisible(bool visible) {
|
||||
impl_->visible_ = visible;
|
||||
}
|
||||
|
||||
bool BookmarkPanel::isVisible() const {
|
||||
return impl_->visible_;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
98
src/ui/bookmark_panel.hpp
Normal file
98
src/ui/bookmark_panel.hpp
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* @file bookmark_panel.hpp
|
||||
* @brief 书签面板组件
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 书签项
|
||||
*/
|
||||
struct BookmarkItem {
|
||||
std::string id;
|
||||
std::string title;
|
||||
std::string url;
|
||||
std::string folder;
|
||||
int64_t created_at{0};
|
||||
int64_t last_visited{0};
|
||||
int visit_count{0};
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 书签面板组件类
|
||||
*/
|
||||
class BookmarkPanel {
|
||||
public:
|
||||
BookmarkPanel();
|
||||
~BookmarkPanel();
|
||||
|
||||
/**
|
||||
* @brief 设置书签列表
|
||||
*/
|
||||
void setBookmarks(const std::vector<BookmarkItem>& bookmarks);
|
||||
|
||||
/**
|
||||
* @brief 获取所有书签
|
||||
*/
|
||||
std::vector<BookmarkItem> getBookmarks() const;
|
||||
|
||||
/**
|
||||
* @brief 添加书签
|
||||
*/
|
||||
void addBookmark(const BookmarkItem& bookmark);
|
||||
|
||||
/**
|
||||
* @brief 删除书签
|
||||
*/
|
||||
void removeBookmark(const std::string& id);
|
||||
|
||||
/**
|
||||
* @brief 选择下一个书签
|
||||
*/
|
||||
void selectNext();
|
||||
|
||||
/**
|
||||
* @brief 选择上一个书签
|
||||
*/
|
||||
void selectPrevious();
|
||||
|
||||
/**
|
||||
* @brief 获取选中的书签索引
|
||||
*/
|
||||
int getSelectedIndex() const;
|
||||
|
||||
/**
|
||||
* @brief 注册书签选择回调
|
||||
*/
|
||||
void onSelect(std::function<void(const BookmarkItem&)> callback);
|
||||
|
||||
/**
|
||||
* @brief 搜索书签
|
||||
*/
|
||||
std::vector<BookmarkItem> search(const std::string& query) const;
|
||||
|
||||
/**
|
||||
* @brief 显示/隐藏面板
|
||||
*/
|
||||
void setVisible(bool visible);
|
||||
|
||||
/**
|
||||
* @brief 是否可见
|
||||
*/
|
||||
bool isVisible() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
116
src/ui/content_view.cpp
Normal file
116
src/ui/content_view.cpp
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* @file content_view.cpp
|
||||
* @brief 内容视图组件实现
|
||||
*/
|
||||
|
||||
#include "ui/content_view.hpp"
|
||||
#include "core/browser_engine.hpp"
|
||||
|
||||
namespace tut {
|
||||
|
||||
class ContentView::Impl {
|
||||
public:
|
||||
std::string content_;
|
||||
std::vector<LinkInfo> links_;
|
||||
int scroll_position_{0};
|
||||
int selected_link_{-1};
|
||||
std::string search_query_;
|
||||
std::vector<int> search_results_;
|
||||
int current_search_result_{-1};
|
||||
std::function<void(const std::string&)> on_link_activate_;
|
||||
};
|
||||
|
||||
ContentView::ContentView() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
ContentView::~ContentView() = default;
|
||||
|
||||
void ContentView::setContent(const std::string& content) {
|
||||
impl_->content_ = content;
|
||||
impl_->scroll_position_ = 0;
|
||||
}
|
||||
|
||||
void ContentView::setLinks(const std::vector<LinkInfo>& links) {
|
||||
impl_->links_ = links;
|
||||
impl_->selected_link_ = links.empty() ? -1 : 0;
|
||||
}
|
||||
|
||||
void ContentView::scrollDown(int lines) {
|
||||
impl_->scroll_position_ += lines;
|
||||
}
|
||||
|
||||
void ContentView::scrollUp(int lines) {
|
||||
impl_->scroll_position_ = std::max(0, impl_->scroll_position_ - lines);
|
||||
}
|
||||
|
||||
void ContentView::scrollToTop() {
|
||||
impl_->scroll_position_ = 0;
|
||||
}
|
||||
|
||||
void ContentView::scrollToBottom() {
|
||||
// TODO: 计算最大滚动位置
|
||||
impl_->scroll_position_ = 99999;
|
||||
}
|
||||
|
||||
void ContentView::pageDown() {
|
||||
scrollDown(20); // TODO: 根据实际视口大小
|
||||
}
|
||||
|
||||
void ContentView::pageUp() {
|
||||
scrollUp(20);
|
||||
}
|
||||
|
||||
int ContentView::getScrollPosition() const {
|
||||
return impl_->scroll_position_;
|
||||
}
|
||||
|
||||
void ContentView::selectNextLink() {
|
||||
if (impl_->links_.empty()) return;
|
||||
impl_->selected_link_ = (impl_->selected_link_ + 1) % static_cast<int>(impl_->links_.size());
|
||||
}
|
||||
|
||||
void ContentView::selectPreviousLink() {
|
||||
if (impl_->links_.empty()) return;
|
||||
impl_->selected_link_--;
|
||||
if (impl_->selected_link_ < 0) {
|
||||
impl_->selected_link_ = static_cast<int>(impl_->links_.size()) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
int ContentView::getSelectedLinkIndex() const {
|
||||
return impl_->selected_link_;
|
||||
}
|
||||
|
||||
void ContentView::onLinkActivate(std::function<void(const std::string&)> callback) {
|
||||
impl_->on_link_activate_ = std::move(callback);
|
||||
}
|
||||
|
||||
int ContentView::search(const std::string& query) {
|
||||
impl_->search_query_ = query;
|
||||
impl_->search_results_.clear();
|
||||
impl_->current_search_result_ = -1;
|
||||
|
||||
// TODO: 实现文本搜索
|
||||
return static_cast<int>(impl_->search_results_.size());
|
||||
}
|
||||
|
||||
void ContentView::nextSearchResult() {
|
||||
if (impl_->search_results_.empty()) return;
|
||||
impl_->current_search_result_ =
|
||||
(impl_->current_search_result_ + 1) % static_cast<int>(impl_->search_results_.size());
|
||||
}
|
||||
|
||||
void ContentView::previousSearchResult() {
|
||||
if (impl_->search_results_.empty()) return;
|
||||
impl_->current_search_result_--;
|
||||
if (impl_->current_search_result_ < 0) {
|
||||
impl_->current_search_result_ = static_cast<int>(impl_->search_results_.size()) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
void ContentView::clearSearch() {
|
||||
impl_->search_query_.clear();
|
||||
impl_->search_results_.clear();
|
||||
impl_->current_search_result_ = -1;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
119
src/ui/content_view.hpp
Normal file
119
src/ui/content_view.hpp
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* @file content_view.hpp
|
||||
* @brief 内容视图组件
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include "../core/types.hpp"
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 内容视图组件类
|
||||
*
|
||||
* 负责显示渲染后的网页内容
|
||||
*/
|
||||
class ContentView {
|
||||
public:
|
||||
ContentView();
|
||||
~ContentView();
|
||||
|
||||
/**
|
||||
* @brief 设置内容
|
||||
*/
|
||||
void setContent(const std::string& content);
|
||||
|
||||
/**
|
||||
* @brief 设置链接列表
|
||||
*/
|
||||
void setLinks(const std::vector<LinkInfo>& links);
|
||||
|
||||
/**
|
||||
* @brief 向下滚动
|
||||
*/
|
||||
void scrollDown(int lines = 1);
|
||||
|
||||
/**
|
||||
* @brief 向上滚动
|
||||
*/
|
||||
void scrollUp(int lines = 1);
|
||||
|
||||
/**
|
||||
* @brief 滚动到顶部
|
||||
*/
|
||||
void scrollToTop();
|
||||
|
||||
/**
|
||||
* @brief 滚动到底部
|
||||
*/
|
||||
void scrollToBottom();
|
||||
|
||||
/**
|
||||
* @brief 向下翻页
|
||||
*/
|
||||
void pageDown();
|
||||
|
||||
/**
|
||||
* @brief 向上翻页
|
||||
*/
|
||||
void pageUp();
|
||||
|
||||
/**
|
||||
* @brief 获取当前滚动位置
|
||||
*/
|
||||
int getScrollPosition() const;
|
||||
|
||||
/**
|
||||
* @brief 选择下一个链接
|
||||
*/
|
||||
void selectNextLink();
|
||||
|
||||
/**
|
||||
* @brief 选择上一个链接
|
||||
*/
|
||||
void selectPreviousLink();
|
||||
|
||||
/**
|
||||
* @brief 获取选中的链接索引
|
||||
*/
|
||||
int getSelectedLinkIndex() const;
|
||||
|
||||
/**
|
||||
* @brief 注册链接点击回调
|
||||
*/
|
||||
void onLinkActivate(std::function<void(const std::string&)> callback);
|
||||
|
||||
/**
|
||||
* @brief 搜索文本
|
||||
* @return 找到的结果数量
|
||||
*/
|
||||
int search(const std::string& query);
|
||||
|
||||
/**
|
||||
* @brief 跳转到下一个搜索结果
|
||||
*/
|
||||
void nextSearchResult();
|
||||
|
||||
/**
|
||||
* @brief 跳转到上一个搜索结果
|
||||
*/
|
||||
void previousSearchResult();
|
||||
|
||||
/**
|
||||
* @brief 清除搜索
|
||||
*/
|
||||
void clearSearch();
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
632
src/ui/main_window.cpp
Normal file
632
src/ui/main_window.cpp
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
/**
|
||||
* @file main_window.cpp
|
||||
* @brief 主窗口实现
|
||||
*/
|
||||
|
||||
#include "ui/main_window.hpp"
|
||||
#include "ui/content_view.hpp"
|
||||
|
||||
#include <ftxui/component/component.hpp>
|
||||
#include <ftxui/component/screen_interactive.hpp>
|
||||
#include <ftxui/dom/elements.hpp>
|
||||
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace tut {
|
||||
|
||||
class MainWindow::Impl {
|
||||
public:
|
||||
std::string url_;
|
||||
std::string title_;
|
||||
std::vector<DisplayLink> links_;
|
||||
int scroll_offset_{0};
|
||||
int selected_link_{-1};
|
||||
int viewport_height_{20};
|
||||
|
||||
std::string status_message_;
|
||||
bool loading_{false};
|
||||
bool can_go_back_{false};
|
||||
bool can_go_forward_{false};
|
||||
|
||||
double load_time_{0.0};
|
||||
size_t load_bytes_{0};
|
||||
int link_count_{0};
|
||||
|
||||
std::function<void(const std::string&)> on_navigate_;
|
||||
std::function<void(WindowEvent)> on_event_;
|
||||
std::function<void(int)> on_link_click_;
|
||||
|
||||
// Split content into lines for scrolling
|
||||
std::vector<std::string> content_lines_;
|
||||
|
||||
// Search state
|
||||
bool search_mode_{false};
|
||||
std::string search_query_;
|
||||
std::vector<int> search_matches_; // Line indices with matches
|
||||
int current_match_{-1}; // Index into search_matches_
|
||||
|
||||
// Bookmark state
|
||||
bool bookmark_panel_visible_{false};
|
||||
std::vector<DisplayBookmark> bookmarks_;
|
||||
int selected_bookmark_{-1};
|
||||
|
||||
// History state
|
||||
bool history_panel_visible_{false};
|
||||
std::vector<DisplayBookmark> history_;
|
||||
int selected_history_{-1};
|
||||
|
||||
void setContent(const std::string& content) {
|
||||
content_lines_.clear();
|
||||
std::istringstream iss(content);
|
||||
std::string line;
|
||||
while (std::getline(iss, line)) {
|
||||
content_lines_.push_back(line);
|
||||
}
|
||||
scroll_offset_ = 0;
|
||||
|
||||
// Clear search state when new content is loaded
|
||||
search_mode_ = false;
|
||||
search_query_.clear();
|
||||
search_matches_.clear();
|
||||
current_match_ = -1;
|
||||
}
|
||||
|
||||
void scrollDown(int lines = 1) {
|
||||
int max_scroll = std::max(0, static_cast<int>(content_lines_.size()) - viewport_height_);
|
||||
scroll_offset_ = std::min(scroll_offset_ + lines, max_scroll);
|
||||
}
|
||||
|
||||
void scrollUp(int lines = 1) {
|
||||
scroll_offset_ = std::max(0, scroll_offset_ - lines);
|
||||
}
|
||||
|
||||
void scrollToTop() {
|
||||
scroll_offset_ = 0;
|
||||
}
|
||||
|
||||
void scrollToBottom() {
|
||||
scroll_offset_ = std::max(0, static_cast<int>(content_lines_.size()) - viewport_height_);
|
||||
}
|
||||
|
||||
void selectNextLink() {
|
||||
if (links_.empty()) return;
|
||||
selected_link_ = (selected_link_ + 1) % static_cast<int>(links_.size());
|
||||
}
|
||||
|
||||
void selectPreviousLink() {
|
||||
if (links_.empty()) return;
|
||||
selected_link_--;
|
||||
if (selected_link_ < 0) {
|
||||
selected_link_ = static_cast<int>(links_.size()) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
void executeSearch() {
|
||||
search_matches_.clear();
|
||||
current_match_ = -1;
|
||||
|
||||
if (search_query_.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert search query to lowercase for case-insensitive search
|
||||
std::string query_lower = search_query_;
|
||||
std::transform(query_lower.begin(), query_lower.end(), query_lower.begin(), ::tolower);
|
||||
|
||||
// Find all lines containing the search query
|
||||
for (size_t i = 0; i < content_lines_.size(); ++i) {
|
||||
std::string line_lower = content_lines_[i];
|
||||
std::transform(line_lower.begin(), line_lower.end(), line_lower.begin(), ::tolower);
|
||||
|
||||
if (line_lower.find(query_lower) != std::string::npos) {
|
||||
search_matches_.push_back(static_cast<int>(i));
|
||||
}
|
||||
}
|
||||
|
||||
if (!search_matches_.empty()) {
|
||||
current_match_ = 0;
|
||||
// Scroll to first match
|
||||
scroll_offset_ = search_matches_[0];
|
||||
}
|
||||
}
|
||||
|
||||
void nextMatch() {
|
||||
if (search_matches_.empty()) return;
|
||||
|
||||
current_match_ = (current_match_ + 1) % static_cast<int>(search_matches_.size());
|
||||
// Scroll to the match
|
||||
scroll_offset_ = search_matches_[current_match_];
|
||||
}
|
||||
|
||||
void previousMatch() {
|
||||
if (search_matches_.empty()) return;
|
||||
|
||||
current_match_--;
|
||||
if (current_match_ < 0) {
|
||||
current_match_ = static_cast<int>(search_matches_.size()) - 1;
|
||||
}
|
||||
// Scroll to the match
|
||||
scroll_offset_ = search_matches_[current_match_];
|
||||
}
|
||||
};
|
||||
|
||||
MainWindow::MainWindow() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
MainWindow::~MainWindow() = default;
|
||||
|
||||
bool MainWindow::init() {
|
||||
return true;
|
||||
}
|
||||
|
||||
int MainWindow::run() {
|
||||
using namespace ftxui;
|
||||
|
||||
auto screen = ScreenInteractive::Fullscreen();
|
||||
|
||||
// 地址栏输入
|
||||
std::string address_content = impl_->url_;
|
||||
auto address_input = Input(&address_content, "Enter URL...");
|
||||
bool address_focused = false;
|
||||
|
||||
// 搜索输入
|
||||
std::string search_content;
|
||||
auto search_input = Input(&search_content, "Search...");
|
||||
bool search_focused = false;
|
||||
|
||||
// 内容渲染器
|
||||
auto content_renderer = Renderer([this] {
|
||||
Elements lines;
|
||||
|
||||
// Title
|
||||
if (!impl_->title_.empty()) {
|
||||
lines.push_back(text(impl_->title_) | bold | center);
|
||||
lines.push_back(separator());
|
||||
}
|
||||
|
||||
// Content with scrolling
|
||||
int start = impl_->scroll_offset_;
|
||||
int end = std::min(start + impl_->viewport_height_,
|
||||
static_cast<int>(impl_->content_lines_.size()));
|
||||
|
||||
for (int i = start; i < end; i++) {
|
||||
// Check if this line is a search match
|
||||
bool is_match = false;
|
||||
bool is_current_match = false;
|
||||
|
||||
if (!impl_->search_matches_.empty()) {
|
||||
auto it = std::find(impl_->search_matches_.begin(),
|
||||
impl_->search_matches_.end(), i);
|
||||
if (it != impl_->search_matches_.end()) {
|
||||
is_match = true;
|
||||
int match_index = std::distance(impl_->search_matches_.begin(), it);
|
||||
is_current_match = (match_index == impl_->current_match_);
|
||||
}
|
||||
}
|
||||
|
||||
// Render line with appropriate highlighting
|
||||
auto line_element = text(impl_->content_lines_[i]);
|
||||
if (is_current_match) {
|
||||
line_element = line_element | bgcolor(Color::Yellow) | color(Color::Black);
|
||||
} else if (is_match) {
|
||||
line_element = line_element | bgcolor(Color::Blue) | color(Color::White);
|
||||
}
|
||||
lines.push_back(line_element);
|
||||
}
|
||||
|
||||
// Scroll indicator
|
||||
if (!impl_->content_lines_.empty()) {
|
||||
int total_lines = static_cast<int>(impl_->content_lines_.size());
|
||||
std::string scroll_info = "Lines " + std::to_string(start + 1) +
|
||||
"-" + std::to_string(end) +
|
||||
" / " + std::to_string(total_lines);
|
||||
lines.push_back(separator());
|
||||
lines.push_back(text(scroll_info) | dim | align_right);
|
||||
}
|
||||
|
||||
return vbox(lines) | flex;
|
||||
});
|
||||
|
||||
// 状态面板
|
||||
auto status_panel = Renderer([this] {
|
||||
Elements status_items;
|
||||
|
||||
// Search status takes priority
|
||||
if (!impl_->search_matches_.empty()) {
|
||||
std::string search_info = "🔍 Match " +
|
||||
std::to_string(impl_->current_match_ + 1) + "/" +
|
||||
std::to_string(impl_->search_matches_.size()) +
|
||||
" for \"" + impl_->search_query_ + "\"";
|
||||
status_items.push_back(text(search_info) | color(Color::Yellow));
|
||||
} else if (impl_->search_mode_ && !impl_->search_query_.empty()) {
|
||||
status_items.push_back(text("🔍 No matches for \"" + impl_->search_query_ + "\"") | dim);
|
||||
} else if (impl_->loading_) {
|
||||
status_items.push_back(text("⏳ Loading...") | dim);
|
||||
} else if (impl_->load_time_ > 0) {
|
||||
std::string stats = "⬇ " + std::to_string(impl_->load_bytes_ / 1024) + " KB " +
|
||||
"🕐 " + std::to_string(static_cast<int>(impl_->load_time_ * 1000)) + "ms " +
|
||||
"🔗 " + std::to_string(impl_->link_count_) + " links";
|
||||
status_items.push_back(text(stats) | dim);
|
||||
} else {
|
||||
status_items.push_back(text("Ready") | dim);
|
||||
}
|
||||
|
||||
if (impl_->selected_link_ >= 0 && impl_->selected_link_ < static_cast<int>(impl_->links_.size()) &&
|
||||
impl_->search_matches_.empty()) {
|
||||
status_items.push_back(separator());
|
||||
std::string link_info = "[" + std::to_string(impl_->selected_link_ + 1) + "] " +
|
||||
impl_->links_[impl_->selected_link_].url;
|
||||
status_items.push_back(text(link_info) | dim);
|
||||
}
|
||||
|
||||
return hbox(status_items);
|
||||
});
|
||||
|
||||
// 主布局
|
||||
auto main_renderer = Renderer([&] {
|
||||
Elements top_bar_elements;
|
||||
|
||||
if (search_focused) {
|
||||
// Show search bar when in search mode
|
||||
top_bar_elements.push_back(hbox({
|
||||
text("🔍 "),
|
||||
search_input->Render() | flex | border | focus,
|
||||
text(" [Esc to cancel]") | dim,
|
||||
}));
|
||||
} else {
|
||||
// Normal address bar
|
||||
top_bar_elements.push_back(hbox({
|
||||
text(impl_->can_go_back_ ? "[◀]" : "[◀]") | (impl_->can_go_back_ ? bold : dim),
|
||||
text(" "),
|
||||
text(impl_->can_go_forward_ ? "[▶]" : "[▶]") | (impl_->can_go_forward_ ? bold : dim),
|
||||
text(" "),
|
||||
text("[⟳]") | bold,
|
||||
text(" "),
|
||||
address_input->Render() | flex | border | (address_focused ? focus : select),
|
||||
text(" "),
|
||||
text("[⚙]") | bold,
|
||||
text(" "),
|
||||
text("[?]") | bold,
|
||||
}));
|
||||
}
|
||||
|
||||
return vbox({
|
||||
// 顶部栏
|
||||
vbox(top_bar_elements),
|
||||
separator(),
|
||||
// 内容区
|
||||
content_renderer->Render() | flex,
|
||||
separator(),
|
||||
// 底部面板
|
||||
hbox({
|
||||
vbox({
|
||||
text("📑 Bookmarks") | bold,
|
||||
[this]() -> Element {
|
||||
if (!impl_->bookmarks_.empty()) {
|
||||
Elements bookmark_lines;
|
||||
int max_display = 5; // Show up to 5 bookmarks
|
||||
int end = std::min(max_display, static_cast<int>(impl_->bookmarks_.size()));
|
||||
for (int i = 0; i < end; i++) {
|
||||
const auto& bm = impl_->bookmarks_[i];
|
||||
auto line = text(" [" + std::to_string(i + 1) + "] " + bm.title);
|
||||
if (i == impl_->selected_bookmark_) {
|
||||
line = line | bold | color(Color::Yellow);
|
||||
} else {
|
||||
line = line | dim;
|
||||
}
|
||||
bookmark_lines.push_back(line);
|
||||
}
|
||||
if (impl_->bookmarks_.size() > static_cast<size_t>(max_display)) {
|
||||
bookmark_lines.push_back(
|
||||
text(" +" + std::to_string(impl_->bookmarks_.size() - max_display) + " more...") | dim
|
||||
);
|
||||
}
|
||||
return vbox(bookmark_lines);
|
||||
} else {
|
||||
return text(" (empty)") | dim;
|
||||
}
|
||||
}()
|
||||
}) | flex,
|
||||
separator(),
|
||||
vbox({
|
||||
text("📚 History") | bold,
|
||||
[this]() -> Element {
|
||||
if (!impl_->history_.empty()) {
|
||||
Elements history_lines;
|
||||
int max_display = 5; // Show up to 5 history entries
|
||||
int end = std::min(max_display, static_cast<int>(impl_->history_.size()));
|
||||
for (int i = 0; i < end; i++) {
|
||||
const auto& entry = impl_->history_[i];
|
||||
auto line = text(" [" + std::to_string(i + 1) + "] " + entry.title);
|
||||
if (i == impl_->selected_history_) {
|
||||
line = line | bold | color(Color::Cyan);
|
||||
} else {
|
||||
line = line | dim;
|
||||
}
|
||||
history_lines.push_back(line);
|
||||
}
|
||||
if (impl_->history_.size() > static_cast<size_t>(max_display)) {
|
||||
history_lines.push_back(
|
||||
text(" +" + std::to_string(impl_->history_.size() - max_display) + " more...") | dim
|
||||
);
|
||||
}
|
||||
return vbox(history_lines);
|
||||
} else {
|
||||
return text(" (empty)") | dim;
|
||||
}
|
||||
}()
|
||||
}) | flex,
|
||||
separator(),
|
||||
vbox({
|
||||
text("📊 Status") | bold,
|
||||
status_panel->Render(),
|
||||
}) | flex,
|
||||
}),
|
||||
separator(),
|
||||
// 状态栏
|
||||
hbox({
|
||||
text("[F1]Help") | dim,
|
||||
text(" "),
|
||||
text("[F2]Bookmarks") | dim,
|
||||
text(" "),
|
||||
text("[F3]History") | dim,
|
||||
text(" "),
|
||||
text("[F10]Quit") | dim,
|
||||
filler(),
|
||||
text(impl_->status_message_) | dim,
|
||||
}),
|
||||
}) | border;
|
||||
});
|
||||
|
||||
// 事件处理
|
||||
main_renderer |= CatchEvent([&](Event event) {
|
||||
// Quit
|
||||
if (event == Event::Escape || event == Event::Character('q') ||
|
||||
event == Event::F10) {
|
||||
screen.ExitLoopClosure()();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Address bar focus (use 'o' key instead of Ctrl+L)
|
||||
if (event == Event::Character('o') && !address_focused) {
|
||||
address_focused = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Navigate from address bar
|
||||
if (event == Event::Return && address_focused) {
|
||||
if (impl_->on_navigate_) {
|
||||
impl_->on_navigate_(address_content);
|
||||
address_focused = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exit address bar
|
||||
if (event == Event::Escape && address_focused) {
|
||||
address_focused = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search mode activation
|
||||
if (event == Event::Character('/') && !address_focused && !search_focused) {
|
||||
search_focused = true;
|
||||
search_content.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Execute search
|
||||
if (event == Event::Return && search_focused) {
|
||||
impl_->search_mode_ = true;
|
||||
impl_->search_query_ = search_content;
|
||||
impl_->executeSearch();
|
||||
search_focused = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exit search mode
|
||||
if (event == Event::Escape && search_focused) {
|
||||
search_focused = false;
|
||||
search_content.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Don't handle other keys if address bar or search is focused
|
||||
if (address_focused || search_focused) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Navigate search results (only when not in input mode)
|
||||
if (event == Event::Character('n') && !impl_->search_matches_.empty()) {
|
||||
impl_->nextMatch();
|
||||
return true;
|
||||
}
|
||||
if (event == Event::Character('N') && !impl_->search_matches_.empty()) {
|
||||
impl_->previousMatch();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scrolling
|
||||
if (event == Event::Character('j') || event == Event::ArrowDown) {
|
||||
impl_->scrollDown(1);
|
||||
return true;
|
||||
}
|
||||
if (event == Event::Character('k') || event == Event::ArrowUp) {
|
||||
impl_->scrollUp(1);
|
||||
return true;
|
||||
}
|
||||
if (event == Event::Character(' ') || event == Event::PageDown) {
|
||||
impl_->scrollDown(impl_->viewport_height_ - 2);
|
||||
return true;
|
||||
}
|
||||
if (event == Event::Character('b') || event == Event::PageUp) {
|
||||
impl_->scrollUp(impl_->viewport_height_ - 2);
|
||||
return true;
|
||||
}
|
||||
if (event == Event::Character('g')) {
|
||||
impl_->scrollToTop();
|
||||
return true;
|
||||
}
|
||||
if (event == Event::Character('G')) {
|
||||
impl_->scrollToBottom();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Link navigation
|
||||
if (event == Event::Tab) {
|
||||
impl_->selectNextLink();
|
||||
return true;
|
||||
}
|
||||
if (event == Event::TabReverse) {
|
||||
impl_->selectPreviousLink();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Follow link
|
||||
if (event == Event::Return) {
|
||||
if (impl_->selected_link_ >= 0 &&
|
||||
impl_->selected_link_ < static_cast<int>(impl_->links_.size())) {
|
||||
if (impl_->on_link_click_) {
|
||||
impl_->on_link_click_(impl_->selected_link_);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Number shortcuts (1-9)
|
||||
if (event.is_character()) {
|
||||
char c = event.character()[0];
|
||||
if (c >= '1' && c <= '9') {
|
||||
int link_idx = c - '1';
|
||||
if (link_idx < static_cast<int>(impl_->links_.size())) {
|
||||
impl_->selected_link_ = link_idx;
|
||||
if (impl_->on_link_click_) {
|
||||
impl_->on_link_click_(link_idx);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Back/Forward
|
||||
if (event == Event::Backspace && impl_->can_go_back_) {
|
||||
if (impl_->on_event_) {
|
||||
impl_->on_event_(WindowEvent::Back);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (event == Event::Character('f') && impl_->can_go_forward_) {
|
||||
if (impl_->on_event_) {
|
||||
impl_->on_event_(WindowEvent::Forward);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Refresh
|
||||
if (event == Event::Character('r') || event == Event::F5) {
|
||||
if (impl_->on_event_) {
|
||||
impl_->on_event_(WindowEvent::Refresh);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add bookmark (Ctrl+D)
|
||||
if (event == Event::Special("\x04")) { // Ctrl+D
|
||||
if (impl_->on_event_) {
|
||||
impl_->on_event_(WindowEvent::AddBookmark);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Toggle bookmark panel (F2)
|
||||
if (event == Event::F2) {
|
||||
impl_->bookmark_panel_visible_ = !impl_->bookmark_panel_visible_;
|
||||
if (impl_->on_event_) {
|
||||
impl_->on_event_(WindowEvent::OpenBookmarks);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Toggle history panel (F3)
|
||||
if (event == Event::F3) {
|
||||
impl_->history_panel_visible_ = !impl_->history_panel_visible_;
|
||||
if (impl_->on_event_) {
|
||||
impl_->on_event_(WindowEvent::OpenHistory);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
screen.Loop(main_renderer);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void MainWindow::setStatusMessage(const std::string& message) {
|
||||
impl_->status_message_ = message;
|
||||
}
|
||||
|
||||
void MainWindow::setUrl(const std::string& url) {
|
||||
impl_->url_ = url;
|
||||
}
|
||||
|
||||
void MainWindow::setTitle(const std::string& title) {
|
||||
impl_->title_ = title;
|
||||
}
|
||||
|
||||
void MainWindow::setContent(const std::string& content) {
|
||||
impl_->setContent(content);
|
||||
}
|
||||
|
||||
void MainWindow::setLoading(bool loading) {
|
||||
impl_->loading_ = loading;
|
||||
}
|
||||
|
||||
void MainWindow::setLinks(const std::vector<DisplayLink>& links) {
|
||||
impl_->links_ = links;
|
||||
impl_->selected_link_ = links.empty() ? -1 : 0;
|
||||
}
|
||||
|
||||
void MainWindow::setBookmarks(const std::vector<DisplayBookmark>& bookmarks) {
|
||||
impl_->bookmarks_ = bookmarks;
|
||||
impl_->selected_bookmark_ = bookmarks.empty() ? -1 : 0;
|
||||
}
|
||||
|
||||
void MainWindow::setHistory(const std::vector<DisplayBookmark>& history) {
|
||||
impl_->history_ = history;
|
||||
impl_->selected_history_ = history.empty() ? -1 : 0;
|
||||
}
|
||||
|
||||
void MainWindow::setCanGoBack(bool can) {
|
||||
impl_->can_go_back_ = can;
|
||||
}
|
||||
|
||||
void MainWindow::setCanGoForward(bool can) {
|
||||
impl_->can_go_forward_ = can;
|
||||
}
|
||||
|
||||
void MainWindow::setLoadStats(double elapsed_seconds, size_t bytes, int link_count) {
|
||||
impl_->load_time_ = elapsed_seconds;
|
||||
impl_->load_bytes_ = bytes;
|
||||
impl_->link_count_ = link_count;
|
||||
}
|
||||
|
||||
void MainWindow::onNavigate(std::function<void(const std::string&)> callback) {
|
||||
impl_->on_navigate_ = std::move(callback);
|
||||
}
|
||||
|
||||
void MainWindow::onEvent(std::function<void(WindowEvent)> callback) {
|
||||
impl_->on_event_ = std::move(callback);
|
||||
}
|
||||
|
||||
void MainWindow::onLinkClick(std::function<void(int index)> callback) {
|
||||
impl_->on_link_click_ = std::move(callback);
|
||||
}
|
||||
|
||||
void MainWindow::onBookmarkClick(std::function<void(const std::string& url)> /*callback*/) {
|
||||
// TODO: Implement bookmark click callback
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
123
src/ui/main_window.hpp
Normal file
123
src/ui/main_window.hpp
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* @file main_window.hpp
|
||||
* @brief 主窗口模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 窗口事件类型
|
||||
*/
|
||||
enum class WindowEvent {
|
||||
None,
|
||||
Quit,
|
||||
Navigate,
|
||||
Back,
|
||||
Forward,
|
||||
Refresh,
|
||||
Search,
|
||||
AddBookmark,
|
||||
OpenBookmarks,
|
||||
OpenHistory,
|
||||
OpenSettings,
|
||||
OpenHelp,
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 链接信息 (用于内容显示)
|
||||
*/
|
||||
struct DisplayLink {
|
||||
std::string text;
|
||||
std::string url;
|
||||
bool visited{false};
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 书签信息 (用于显示)
|
||||
*/
|
||||
struct DisplayBookmark {
|
||||
std::string title;
|
||||
std::string url;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 主窗口类
|
||||
*
|
||||
* 负责整体 UI 布局和事件协调
|
||||
* 采用 btop 风格的四分区布局
|
||||
*/
|
||||
class MainWindow {
|
||||
public:
|
||||
MainWindow();
|
||||
~MainWindow();
|
||||
|
||||
/**
|
||||
* @brief 初始化窗口
|
||||
*/
|
||||
bool init();
|
||||
|
||||
/**
|
||||
* @brief 运行主事件循环
|
||||
*/
|
||||
int run();
|
||||
|
||||
// ========== 状态设置 ==========
|
||||
|
||||
void setStatusMessage(const std::string& message);
|
||||
void setUrl(const std::string& url);
|
||||
void setTitle(const std::string& title);
|
||||
void setContent(const std::string& content);
|
||||
void setLoading(bool loading);
|
||||
|
||||
// ========== 内容管理 ==========
|
||||
|
||||
/**
|
||||
* @brief 设置页面链接列表
|
||||
*/
|
||||
void setLinks(const std::vector<DisplayLink>& links);
|
||||
|
||||
/**
|
||||
* @brief 设置书签列表
|
||||
*/
|
||||
void setBookmarks(const std::vector<DisplayBookmark>& bookmarks);
|
||||
|
||||
/**
|
||||
* @brief 设置历史记录列表
|
||||
*/
|
||||
void setHistory(const std::vector<DisplayBookmark>& history);
|
||||
|
||||
/**
|
||||
* @brief 设置导航状态
|
||||
*/
|
||||
void setCanGoBack(bool can);
|
||||
void setCanGoForward(bool can);
|
||||
|
||||
// ========== 统计信息 ==========
|
||||
|
||||
/**
|
||||
* @brief 设置加载统计
|
||||
*/
|
||||
void setLoadStats(double elapsed_seconds, size_t bytes, int link_count);
|
||||
|
||||
// ========== 回调注册 ==========
|
||||
|
||||
void onNavigate(std::function<void(const std::string&)> callback);
|
||||
void onEvent(std::function<void(WindowEvent)> callback);
|
||||
void onLinkClick(std::function<void(int index)> callback);
|
||||
void onBookmarkClick(std::function<void(const std::string& url)> callback);
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
48
src/ui/status_bar.cpp
Normal file
48
src/ui/status_bar.cpp
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* @file status_bar.cpp
|
||||
* @brief 状态栏组件实现
|
||||
*/
|
||||
|
||||
#include "ui/status_bar.hpp"
|
||||
|
||||
namespace tut {
|
||||
|
||||
class StatusBar::Impl {
|
||||
public:
|
||||
std::string message_;
|
||||
LoadingStatus loading_status_;
|
||||
};
|
||||
|
||||
StatusBar::StatusBar() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
StatusBar::~StatusBar() = default;
|
||||
|
||||
void StatusBar::setMessage(const std::string& message) {
|
||||
impl_->message_ = message;
|
||||
}
|
||||
|
||||
std::string StatusBar::getMessage() const {
|
||||
return impl_->message_;
|
||||
}
|
||||
|
||||
void StatusBar::setLoadingStatus(const LoadingStatus& status) {
|
||||
impl_->loading_status_ = status;
|
||||
}
|
||||
|
||||
LoadingStatus StatusBar::getLoadingStatus() const {
|
||||
return impl_->loading_status_;
|
||||
}
|
||||
|
||||
void StatusBar::showError(const std::string& error) {
|
||||
impl_->message_ = "[ERROR] " + error;
|
||||
}
|
||||
|
||||
void StatusBar::showSuccess(const std::string& message) {
|
||||
impl_->message_ = "[OK] " + message;
|
||||
}
|
||||
|
||||
void StatusBar::clear() {
|
||||
impl_->message_.clear();
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
74
src/ui/status_bar.hpp
Normal file
74
src/ui/status_bar.hpp
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* @file status_bar.hpp
|
||||
* @brief 状态栏组件
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 加载状态
|
||||
*/
|
||||
struct LoadingStatus {
|
||||
bool is_loading{false};
|
||||
size_t bytes_downloaded{0};
|
||||
size_t bytes_total{0};
|
||||
double elapsed_time{0.0};
|
||||
int link_count{0};
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 状态栏组件类
|
||||
*/
|
||||
class StatusBar {
|
||||
public:
|
||||
StatusBar();
|
||||
~StatusBar();
|
||||
|
||||
/**
|
||||
* @brief 设置消息
|
||||
*/
|
||||
void setMessage(const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief 获取消息
|
||||
*/
|
||||
std::string getMessage() const;
|
||||
|
||||
/**
|
||||
* @brief 设置加载状态
|
||||
*/
|
||||
void setLoadingStatus(const LoadingStatus& status);
|
||||
|
||||
/**
|
||||
* @brief 获取加载状态
|
||||
*/
|
||||
LoadingStatus getLoadingStatus() const;
|
||||
|
||||
/**
|
||||
* @brief 显示错误信息
|
||||
*/
|
||||
void showError(const std::string& error);
|
||||
|
||||
/**
|
||||
* @brief 显示成功信息
|
||||
*/
|
||||
void showSuccess(const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief 清除消息
|
||||
*/
|
||||
void clear();
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
219
src/utils/config.cpp
Normal file
219
src/utils/config.cpp
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/**
|
||||
* @file config.cpp
|
||||
* @brief 配置管理实现
|
||||
*/
|
||||
|
||||
#include "utils/config.hpp"
|
||||
#include "utils/logger.hpp"
|
||||
|
||||
#include <toml.hpp>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace tut {
|
||||
|
||||
class Config::Impl {
|
||||
public:
|
||||
std::string config_path_;
|
||||
toml::value config_;
|
||||
std::map<std::string, std::string> string_values_;
|
||||
std::map<std::string, int> int_values_;
|
||||
std::map<std::string, bool> bool_values_;
|
||||
std::map<std::string, double> double_values_;
|
||||
};
|
||||
|
||||
Config& Config::instance() {
|
||||
static Config instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
Config::Config() : impl_(std::make_unique<Impl>()) {
|
||||
loadDefaults();
|
||||
}
|
||||
|
||||
Config::~Config() = default;
|
||||
|
||||
bool Config::load(const std::string& filepath) {
|
||||
impl_->config_path_ = filepath;
|
||||
|
||||
try {
|
||||
impl_->config_ = toml::parse(filepath);
|
||||
|
||||
// 解析配置到内部映射
|
||||
if (impl_->config_.contains("general")) {
|
||||
auto& general = impl_->config_["general"];
|
||||
if (general.contains("home_page")) {
|
||||
impl_->string_values_["general.home_page"] =
|
||||
toml::find<std::string>(general, "home_page");
|
||||
}
|
||||
if (general.contains("default_theme")) {
|
||||
impl_->string_values_["general.default_theme"] =
|
||||
toml::find<std::string>(general, "default_theme");
|
||||
}
|
||||
}
|
||||
|
||||
if (impl_->config_.contains("network")) {
|
||||
auto& network = impl_->config_["network"];
|
||||
if (network.contains("timeout")) {
|
||||
impl_->int_values_["network.timeout"] =
|
||||
toml::find<int>(network, "timeout");
|
||||
}
|
||||
if (network.contains("max_redirects")) {
|
||||
impl_->int_values_["network.max_redirects"] =
|
||||
toml::find<int>(network, "max_redirects");
|
||||
}
|
||||
}
|
||||
|
||||
if (impl_->config_.contains("rendering")) {
|
||||
auto& rendering = impl_->config_["rendering"];
|
||||
if (rendering.contains("show_images")) {
|
||||
impl_->bool_values_["rendering.show_images"] =
|
||||
toml::find<bool>(rendering, "show_images");
|
||||
}
|
||||
if (rendering.contains("javascript_enabled")) {
|
||||
impl_->bool_values_["rendering.javascript_enabled"] =
|
||||
toml::find<bool>(rendering, "javascript_enabled");
|
||||
}
|
||||
}
|
||||
|
||||
LOG_INFO << "Configuration loaded from: " << filepath;
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to load configuration: " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Config::save(const std::string& filepath) const {
|
||||
try {
|
||||
std::ofstream ofs(filepath);
|
||||
if (!ofs) {
|
||||
LOG_ERROR << "Failed to open file for writing: " << filepath;
|
||||
return false;
|
||||
}
|
||||
|
||||
ofs << impl_->config_;
|
||||
LOG_INFO << "Configuration saved to: " << filepath;
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to save configuration: " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool Config::reload() {
|
||||
if (impl_->config_path_.empty()) {
|
||||
return false;
|
||||
}
|
||||
return load(impl_->config_path_);
|
||||
}
|
||||
|
||||
std::optional<std::string> Config::getString(const std::string& key) const {
|
||||
auto it = impl_->string_values_.find(key);
|
||||
if (it != impl_->string_values_.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<int> Config::getInt(const std::string& key) const {
|
||||
auto it = impl_->int_values_.find(key);
|
||||
if (it != impl_->int_values_.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<bool> Config::getBool(const std::string& key) const {
|
||||
auto it = impl_->bool_values_.find(key);
|
||||
if (it != impl_->bool_values_.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<double> Config::getDouble(const std::string& key) const {
|
||||
auto it = impl_->double_values_.find(key);
|
||||
if (it != impl_->double_values_.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void Config::set(const std::string& key, const std::string& value) {
|
||||
impl_->string_values_[key] = value;
|
||||
}
|
||||
|
||||
void Config::set(const std::string& key, int value) {
|
||||
impl_->int_values_[key] = value;
|
||||
}
|
||||
|
||||
void Config::set(const std::string& key, bool value) {
|
||||
impl_->bool_values_[key] = value;
|
||||
}
|
||||
|
||||
void Config::set(const std::string& key, double value) {
|
||||
impl_->double_values_[key] = value;
|
||||
}
|
||||
|
||||
std::string Config::getConfigPath() const {
|
||||
return expandPath("~/.config/tut");
|
||||
}
|
||||
|
||||
std::string Config::getDataPath() const {
|
||||
return expandPath("~/.local/share/tut");
|
||||
}
|
||||
|
||||
std::string Config::getCachePath() const {
|
||||
return expandPath("~/.cache/tut");
|
||||
}
|
||||
|
||||
std::string Config::getHomePage() const {
|
||||
return getString("general.home_page").value_or("about:blank");
|
||||
}
|
||||
|
||||
std::string Config::getDefaultTheme() const {
|
||||
return getString("general.default_theme").value_or("default");
|
||||
}
|
||||
|
||||
int Config::getHttpTimeout() const {
|
||||
return getInt("network.timeout").value_or(30);
|
||||
}
|
||||
|
||||
int Config::getMaxRedirects() const {
|
||||
return getInt("network.max_redirects").value_or(5);
|
||||
}
|
||||
|
||||
bool Config::getShowImages() const {
|
||||
return getBool("rendering.show_images").value_or(false);
|
||||
}
|
||||
|
||||
bool Config::getJavaScriptEnabled() const {
|
||||
return getBool("rendering.javascript_enabled").value_or(false);
|
||||
}
|
||||
|
||||
void Config::loadDefaults() {
|
||||
impl_->string_values_["general.home_page"] = "about:blank";
|
||||
impl_->string_values_["general.default_theme"] = "default";
|
||||
impl_->int_values_["network.timeout"] = 30;
|
||||
impl_->int_values_["network.max_redirects"] = 5;
|
||||
impl_->bool_values_["rendering.show_images"] = false;
|
||||
impl_->bool_values_["rendering.javascript_enabled"] = false;
|
||||
}
|
||||
|
||||
std::string Config::expandPath(const std::string& path) const {
|
||||
if (path.empty() || path[0] != '~') {
|
||||
return path;
|
||||
}
|
||||
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return std::string(home) + path.substr(1);
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
114
src/utils/config.hpp
Normal file
114
src/utils/config.hpp
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* @file config.hpp
|
||||
* @brief 配置管理模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 配置管理类
|
||||
*
|
||||
* 管理应用程序配置,支持 TOML 格式
|
||||
*/
|
||||
class Config {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取全局配置实例
|
||||
*/
|
||||
static Config& instance();
|
||||
|
||||
/**
|
||||
* @brief 从文件加载配置
|
||||
* @param filepath 配置文件路径
|
||||
* @return 加载成功返回 true
|
||||
*/
|
||||
bool load(const std::string& filepath);
|
||||
|
||||
/**
|
||||
* @brief 保存配置到文件
|
||||
* @param filepath 配置文件路径
|
||||
* @return 保存成功返回 true
|
||||
*/
|
||||
bool save(const std::string& filepath) const;
|
||||
|
||||
/**
|
||||
* @brief 重新加载配置
|
||||
* @return 重新加载成功返回 true
|
||||
*/
|
||||
bool reload();
|
||||
|
||||
/**
|
||||
* @brief 获取字符串配置项
|
||||
*/
|
||||
std::optional<std::string> getString(const std::string& key) const;
|
||||
|
||||
/**
|
||||
* @brief 获取整数配置项
|
||||
*/
|
||||
std::optional<int> getInt(const std::string& key) const;
|
||||
|
||||
/**
|
||||
* @brief 获取布尔配置项
|
||||
*/
|
||||
std::optional<bool> getBool(const std::string& key) const;
|
||||
|
||||
/**
|
||||
* @brief 获取浮点配置项
|
||||
*/
|
||||
std::optional<double> getDouble(const std::string& key) const;
|
||||
|
||||
/**
|
||||
* @brief 设置配置项
|
||||
*/
|
||||
void set(const std::string& key, const std::string& value);
|
||||
void set(const std::string& key, int value);
|
||||
void set(const std::string& key, bool value);
|
||||
void set(const std::string& key, double value);
|
||||
|
||||
/**
|
||||
* @brief 获取配置文件路径
|
||||
*/
|
||||
std::string getConfigPath() const;
|
||||
|
||||
/**
|
||||
* @brief 获取数据目录路径
|
||||
*/
|
||||
std::string getDataPath() const;
|
||||
|
||||
/**
|
||||
* @brief 获取缓存目录路径
|
||||
*/
|
||||
std::string getCachePath() const;
|
||||
|
||||
// 常用配置项的便捷访问器
|
||||
std::string getHomePage() const;
|
||||
std::string getDefaultTheme() const;
|
||||
int getHttpTimeout() const;
|
||||
int getMaxRedirects() const;
|
||||
bool getShowImages() const;
|
||||
bool getJavaScriptEnabled() const;
|
||||
|
||||
private:
|
||||
Config();
|
||||
~Config();
|
||||
|
||||
Config(const Config&) = delete;
|
||||
Config& operator=(const Config&) = delete;
|
||||
|
||||
void loadDefaults();
|
||||
std::string expandPath(const std::string& path) const;
|
||||
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
123
src/utils/logger.cpp
Normal file
123
src/utils/logger.cpp
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* @file logger.cpp
|
||||
* @brief 日志系统实现
|
||||
*/
|
||||
|
||||
#include "utils/logger.hpp"
|
||||
#include <iostream>
|
||||
#include <chrono>
|
||||
#include <iomanip>
|
||||
#include <ctime>
|
||||
|
||||
namespace tut {
|
||||
|
||||
Logger& Logger::instance() {
|
||||
static Logger instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
Logger::Logger() = default;
|
||||
|
||||
Logger::~Logger() {
|
||||
flush();
|
||||
}
|
||||
|
||||
void Logger::setLevel(LogLevel level) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
level_ = level;
|
||||
}
|
||||
|
||||
LogLevel Logger::getLevel() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return level_;
|
||||
}
|
||||
|
||||
bool Logger::setFile(const std::string& filepath) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (file_.is_open()) {
|
||||
file_.close();
|
||||
}
|
||||
file_.open(filepath, std::ios::app);
|
||||
return file_.is_open();
|
||||
}
|
||||
|
||||
void Logger::setConsoleOutput(bool enabled) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
console_output_ = enabled;
|
||||
}
|
||||
|
||||
void Logger::log(LogLevel level, const char* file, int line, const std::string& message) {
|
||||
if (level < level_) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << "[" << getCurrentTime() << "] "
|
||||
<< "[" << levelToString(level) << "] "
|
||||
<< "[" << file << ":" << line << "] "
|
||||
<< message;
|
||||
|
||||
std::string log_line = oss.str();
|
||||
|
||||
if (console_output_) {
|
||||
// 根据级别设置颜色
|
||||
const char* color = "\033[0m";
|
||||
switch (level) {
|
||||
case LogLevel::Trace: color = "\033[90m"; break; // Gray
|
||||
case LogLevel::Debug: color = "\033[36m"; break; // Cyan
|
||||
case LogLevel::Info: color = "\033[32m"; break; // Green
|
||||
case LogLevel::Warn: color = "\033[33m"; break; // Yellow
|
||||
case LogLevel::Error: color = "\033[31m"; break; // Red
|
||||
case LogLevel::Fatal: color = "\033[35m"; break; // Magenta
|
||||
default: break;
|
||||
}
|
||||
|
||||
std::cerr << color << log_line << "\033[0m" << std::endl;
|
||||
}
|
||||
|
||||
if (file_.is_open()) {
|
||||
file_ << log_line << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
void Logger::flush() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (file_.is_open()) {
|
||||
file_.flush();
|
||||
}
|
||||
}
|
||||
|
||||
std::string Logger::levelToString(LogLevel level) const {
|
||||
switch (level) {
|
||||
case LogLevel::Trace: return "TRACE";
|
||||
case LogLevel::Debug: return "DEBUG";
|
||||
case LogLevel::Info: return "INFO ";
|
||||
case LogLevel::Warn: return "WARN ";
|
||||
case LogLevel::Error: return "ERROR";
|
||||
case LogLevel::Fatal: return "FATAL";
|
||||
default: return "?????";
|
||||
}
|
||||
}
|
||||
|
||||
std::string Logger::getCurrentTime() const {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto time = std::chrono::system_clock::to_time_t(now);
|
||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now.time_since_epoch()) % 1000;
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S")
|
||||
<< '.' << std::setfill('0') << std::setw(3) << ms.count();
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
LogStream::LogStream(LogLevel level, const char* file, int line)
|
||||
: level_(level), file_(file), line_(line) {}
|
||||
|
||||
LogStream::~LogStream() {
|
||||
Logger::instance().log(level_, file_, line_, stream_.str());
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
124
src/utils/logger.hpp
Normal file
124
src/utils/logger.hpp
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* @file logger.hpp
|
||||
* @brief 日志系统模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 日志级别
|
||||
*/
|
||||
enum class LogLevel {
|
||||
Trace = 0,
|
||||
Debug = 1,
|
||||
Info = 2,
|
||||
Warn = 3,
|
||||
Error = 4,
|
||||
Fatal = 5,
|
||||
Off = 6
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 日志记录器类
|
||||
*
|
||||
* 线程安全的日志系统
|
||||
*/
|
||||
class Logger {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取全局日志实例
|
||||
*/
|
||||
static Logger& instance();
|
||||
|
||||
/**
|
||||
* @brief 设置日志级别
|
||||
*/
|
||||
void setLevel(LogLevel level);
|
||||
|
||||
/**
|
||||
* @brief 获取当前日志级别
|
||||
*/
|
||||
LogLevel getLevel() const;
|
||||
|
||||
/**
|
||||
* @brief 设置日志文件
|
||||
* @param filepath 日志文件路径
|
||||
* @return 设置成功返回 true
|
||||
*/
|
||||
bool setFile(const std::string& filepath);
|
||||
|
||||
/**
|
||||
* @brief 启用/禁用控制台输出
|
||||
*/
|
||||
void setConsoleOutput(bool enabled);
|
||||
|
||||
/**
|
||||
* @brief 记录日志
|
||||
* @param level 日志级别
|
||||
* @param file 源文件名
|
||||
* @param line 行号
|
||||
* @param message 日志消息
|
||||
*/
|
||||
void log(LogLevel level, const char* file, int line, const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief 刷新日志缓冲
|
||||
*/
|
||||
void flush();
|
||||
|
||||
private:
|
||||
Logger();
|
||||
~Logger();
|
||||
|
||||
Logger(const Logger&) = delete;
|
||||
Logger& operator=(const Logger&) = delete;
|
||||
|
||||
std::string levelToString(LogLevel level) const;
|
||||
std::string getCurrentTime() const;
|
||||
|
||||
LogLevel level_{LogLevel::Info};
|
||||
std::ofstream file_;
|
||||
bool console_output_{true};
|
||||
mutable std::mutex mutex_;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 日志流辅助类
|
||||
*/
|
||||
class LogStream {
|
||||
public:
|
||||
LogStream(LogLevel level, const char* file, int line);
|
||||
~LogStream();
|
||||
|
||||
template<typename T>
|
||||
LogStream& operator<<(const T& value) {
|
||||
stream_ << value;
|
||||
return *this;
|
||||
}
|
||||
|
||||
private:
|
||||
LogLevel level_;
|
||||
const char* file_;
|
||||
int line_;
|
||||
std::ostringstream stream_;
|
||||
};
|
||||
|
||||
// 日志宏
|
||||
#define LOG_TRACE tut::LogStream(tut::LogLevel::Trace, __FILE__, __LINE__)
|
||||
#define LOG_DEBUG tut::LogStream(tut::LogLevel::Debug, __FILE__, __LINE__)
|
||||
#define LOG_INFO tut::LogStream(tut::LogLevel::Info, __FILE__, __LINE__)
|
||||
#define LOG_WARN tut::LogStream(tut::LogLevel::Warn, __FILE__, __LINE__)
|
||||
#define LOG_ERROR tut::LogStream(tut::LogLevel::Error, __FILE__, __LINE__)
|
||||
#define LOG_FATAL tut::LogStream(tut::LogLevel::Fatal, __FILE__, __LINE__)
|
||||
|
||||
} // namespace tut
|
||||
7988
src/utils/stb_image.h
Normal file
7988
src/utils/stb_image.h
Normal file
File diff suppressed because it is too large
Load diff
207
src/utils/theme.cpp
Normal file
207
src/utils/theme.cpp
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* @file theme.cpp
|
||||
* @brief 主题管理实现
|
||||
*/
|
||||
|
||||
#include "utils/theme.hpp"
|
||||
#include "utils/logger.hpp"
|
||||
|
||||
#include <toml.hpp>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace tut {
|
||||
|
||||
class ThemeManager::Impl {
|
||||
public:
|
||||
std::map<std::string, Theme> themes_;
|
||||
std::string current_theme_name_{"default"};
|
||||
Theme current_theme_;
|
||||
|
||||
bool parseColor(const toml::value& value, Color& color) {
|
||||
try {
|
||||
std::string hex = toml::get<std::string>(value);
|
||||
auto parsed = Color::fromHex(hex);
|
||||
if (parsed) {
|
||||
color = *parsed;
|
||||
return true;
|
||||
}
|
||||
} catch (...) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
Theme parseTheme(const toml::value& config, const std::string& name) {
|
||||
Theme theme;
|
||||
theme.name = name;
|
||||
|
||||
if (config.contains("colors")) {
|
||||
auto& colors = config.at("colors");
|
||||
if (colors.contains("background")) parseColor(colors.at("background"), theme.background);
|
||||
if (colors.contains("foreground")) parseColor(colors.at("foreground"), theme.foreground);
|
||||
if (colors.contains("accent")) parseColor(colors.at("accent"), theme.accent);
|
||||
if (colors.contains("border")) parseColor(colors.at("border"), theme.border);
|
||||
if (colors.contains("selection")) parseColor(colors.at("selection"), theme.selection);
|
||||
if (colors.contains("link")) parseColor(colors.at("link"), theme.link);
|
||||
if (colors.contains("visited_link")) parseColor(colors.at("visited_link"), theme.visited_link);
|
||||
if (colors.contains("error")) parseColor(colors.at("error"), theme.error);
|
||||
if (colors.contains("success")) parseColor(colors.at("success"), theme.success);
|
||||
if (colors.contains("warning")) parseColor(colors.at("warning"), theme.warning);
|
||||
}
|
||||
|
||||
if (config.contains("ui")) {
|
||||
auto& ui = config.at("ui");
|
||||
if (ui.contains("border_style")) {
|
||||
theme.border_style = toml::find<std::string>(ui, "border_style");
|
||||
}
|
||||
if (ui.contains("show_shadows")) {
|
||||
theme.show_shadows = toml::find<bool>(ui, "show_shadows");
|
||||
}
|
||||
if (ui.contains("transparency")) {
|
||||
theme.transparency = toml::find<bool>(ui, "transparency");
|
||||
}
|
||||
}
|
||||
|
||||
if (config.contains("meta")) {
|
||||
auto& meta = config.at("meta");
|
||||
if (meta.contains("description")) {
|
||||
theme.description = toml::find<std::string>(meta, "description");
|
||||
}
|
||||
}
|
||||
|
||||
return theme;
|
||||
}
|
||||
};
|
||||
|
||||
ThemeManager& ThemeManager::instance() {
|
||||
static ThemeManager instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
ThemeManager::ThemeManager() : impl_(std::make_unique<Impl>()) {
|
||||
loadDefaultTheme();
|
||||
}
|
||||
|
||||
ThemeManager::~ThemeManager() = default;
|
||||
|
||||
bool ThemeManager::loadTheme(const std::string& filepath) {
|
||||
try {
|
||||
auto config = toml::parse(filepath);
|
||||
|
||||
// 从文件名获取主题名称
|
||||
fs::path path(filepath);
|
||||
std::string name = path.stem().string();
|
||||
|
||||
Theme theme = impl_->parseTheme(config, name);
|
||||
impl_->themes_[name] = theme;
|
||||
|
||||
LOG_INFO << "Loaded theme: " << name;
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to load theme from " << filepath << ": " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int ThemeManager::loadThemesFromDirectory(const std::string& directory) {
|
||||
int count = 0;
|
||||
|
||||
try {
|
||||
for (const auto& entry : fs::directory_iterator(directory)) {
|
||||
if (entry.path().extension() == ".toml") {
|
||||
if (loadTheme(entry.path().string())) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to read theme directory: " << e.what();
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
bool ThemeManager::setTheme(const std::string& name) {
|
||||
auto it = impl_->themes_.find(name);
|
||||
if (it == impl_->themes_.end()) {
|
||||
LOG_WARN << "Theme not found: " << name;
|
||||
return false;
|
||||
}
|
||||
|
||||
impl_->current_theme_name_ = name;
|
||||
impl_->current_theme_ = it->second;
|
||||
LOG_INFO << "Theme set to: " << name;
|
||||
return true;
|
||||
}
|
||||
|
||||
const Theme& ThemeManager::getCurrentTheme() const {
|
||||
return impl_->current_theme_;
|
||||
}
|
||||
|
||||
std::vector<std::string> ThemeManager::getThemeNames() const {
|
||||
std::vector<std::string> names;
|
||||
for (const auto& [name, _] : impl_->themes_) {
|
||||
names.push_back(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
bool ThemeManager::hasTheme(const std::string& name) const {
|
||||
return impl_->themes_.find(name) != impl_->themes_.end();
|
||||
}
|
||||
|
||||
const Theme* ThemeManager::getTheme(const std::string& name) const {
|
||||
auto it = impl_->themes_.find(name);
|
||||
if (it != impl_->themes_.end()) {
|
||||
return &it->second;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ThemeManager::saveTheme(const std::string& filepath) const {
|
||||
try {
|
||||
const Theme& theme = impl_->current_theme_;
|
||||
|
||||
toml::value config = toml::table{
|
||||
{"meta", toml::table{
|
||||
{"name", theme.name},
|
||||
{"description", theme.description}
|
||||
}},
|
||||
{"colors", toml::table{
|
||||
{"background", theme.background.toHex()},
|
||||
{"foreground", theme.foreground.toHex()},
|
||||
{"accent", theme.accent.toHex()},
|
||||
{"border", theme.border.toHex()},
|
||||
{"selection", theme.selection.toHex()},
|
||||
{"link", theme.link.toHex()},
|
||||
{"visited_link", theme.visited_link.toHex()},
|
||||
{"error", theme.error.toHex()},
|
||||
{"success", theme.success.toHex()},
|
||||
{"warning", theme.warning.toHex()}
|
||||
}},
|
||||
{"ui", toml::table{
|
||||
{"border_style", theme.border_style},
|
||||
{"show_shadows", theme.show_shadows},
|
||||
{"transparency", theme.transparency}
|
||||
}}
|
||||
};
|
||||
|
||||
std::ofstream ofs(filepath);
|
||||
ofs << config;
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to save theme: " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void ThemeManager::loadDefaultTheme() {
|
||||
Theme theme;
|
||||
theme.name = "default";
|
||||
theme.description = "Default dark theme";
|
||||
impl_->themes_["default"] = theme;
|
||||
impl_->current_theme_ = theme;
|
||||
}
|
||||
|
||||
} // namespace tut
|
||||
115
src/utils/theme.hpp
Normal file
115
src/utils/theme.hpp
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* @file theme.hpp
|
||||
* @brief 主题管理模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include "renderer/style_parser.hpp"
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 主题配置
|
||||
*/
|
||||
struct Theme {
|
||||
std::string name;
|
||||
std::string description;
|
||||
|
||||
// 颜色配置
|
||||
Color background{0x1e, 0x1e, 0x2e};
|
||||
Color foreground{0xcd, 0xd6, 0xf4};
|
||||
Color accent{0x89, 0xb4, 0xfa};
|
||||
Color border{0x45, 0x47, 0x5a};
|
||||
Color selection{0x31, 0x32, 0x44};
|
||||
Color link{0x74, 0xc7, 0xec};
|
||||
Color visited_link{0xb4, 0xbe, 0xfe};
|
||||
Color error{0xf3, 0x8b, 0xa8};
|
||||
Color success{0xa6, 0xe3, 0xa1};
|
||||
Color warning{0xfa, 0xb3, 0x87};
|
||||
|
||||
// UI 配置
|
||||
std::string border_style{"rounded"}; // rounded, sharp, double, none
|
||||
bool show_shadows{true};
|
||||
bool transparency{false};
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 主题管理器类
|
||||
*/
|
||||
class ThemeManager {
|
||||
public:
|
||||
/**
|
||||
* @brief 获取全局实例
|
||||
*/
|
||||
static ThemeManager& instance();
|
||||
|
||||
/**
|
||||
* @brief 从文件加载主题
|
||||
* @param filepath 主题文件路径
|
||||
* @return 加载成功返回 true
|
||||
*/
|
||||
bool loadTheme(const std::string& filepath);
|
||||
|
||||
/**
|
||||
* @brief 从目录加载所有主题
|
||||
* @param directory 主题目录
|
||||
* @return 加载的主题数量
|
||||
*/
|
||||
int loadThemesFromDirectory(const std::string& directory);
|
||||
|
||||
/**
|
||||
* @brief 设置当前主题
|
||||
* @param name 主题名称
|
||||
* @return 设置成功返回 true
|
||||
*/
|
||||
bool setTheme(const std::string& name);
|
||||
|
||||
/**
|
||||
* @brief 获取当前主题
|
||||
*/
|
||||
const Theme& getCurrentTheme() const;
|
||||
|
||||
/**
|
||||
* @brief 获取主题名称列表
|
||||
*/
|
||||
std::vector<std::string> getThemeNames() const;
|
||||
|
||||
/**
|
||||
* @brief 检查主题是否存在
|
||||
*/
|
||||
bool hasTheme(const std::string& name) const;
|
||||
|
||||
/**
|
||||
* @brief 获取主题
|
||||
* @param name 主题名称
|
||||
* @return 主题指针,不存在返回 nullptr
|
||||
*/
|
||||
const Theme* getTheme(const std::string& name) const;
|
||||
|
||||
/**
|
||||
* @brief 保存当前主题到文件
|
||||
* @param filepath 保存路径
|
||||
* @return 保存成功返回 true
|
||||
*/
|
||||
bool saveTheme(const std::string& filepath) const;
|
||||
|
||||
private:
|
||||
ThemeManager();
|
||||
~ThemeManager();
|
||||
|
||||
ThemeManager(const ThemeManager&) = delete;
|
||||
ThemeManager& operator=(const ThemeManager&) = delete;
|
||||
|
||||
void loadDefaultTheme();
|
||||
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace tut
|
||||
32
test_browse.sh
Executable file
32
test_browse.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
# Quick test script to verify TUT can browse web pages
|
||||
|
||||
echo "=== TUT Browser Test ==="
|
||||
echo ""
|
||||
echo "Testing basic web browsing functionality..."
|
||||
echo ""
|
||||
|
||||
# Test 1: TLDP HOWTOs page
|
||||
echo "Test 1: Loading TLDP HOWTO index..."
|
||||
timeout 3 ./build_ftxui/tut https://tldp.org/HOWTO/HOWTO-INDEX/howtos.html 2>&1 | grep -A 5 "Single list of HOWTOs" && echo "✅ PASSED" || echo "❌ FAILED"
|
||||
|
||||
echo ""
|
||||
echo "Test 2: Loading example.com..."
|
||||
timeout 3 ./build_ftxui/tut https://example.com 2>&1 | grep -A 3 "Example Domain" && echo "✅ PASSED" || echo "❌ FAILED"
|
||||
|
||||
echo ""
|
||||
echo "=== Test Complete ==="
|
||||
echo ""
|
||||
echo "The browser successfully:"
|
||||
echo " ✅ Fetches web pages via HTTP/HTTPS"
|
||||
echo " ✅ Parses HTML with gumbo-parser"
|
||||
echo " ✅ Renders content with formatting"
|
||||
echo " ✅ Extracts and numbers links"
|
||||
echo ""
|
||||
echo "Current limitations:"
|
||||
echo " ⚠ Link navigation not yet interactive (UI components pending)"
|
||||
echo " ⚠ No bookmark/history persistence yet"
|
||||
echo " ⚠ No back/forward navigation in UI"
|
||||
echo ""
|
||||
echo "Try it manually: ./build_ftxui/tut https://example.com"
|
||||
echo "Press 'q' or Ctrl+Q to quit"
|
||||
|
|
@ -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>
|
||||
58
tests/CMakeLists.txt
Normal file
58
tests/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# tests/CMakeLists.txt
|
||||
# TUT 测试配置
|
||||
|
||||
# ============================================================================
|
||||
# 单元测试
|
||||
# ============================================================================
|
||||
|
||||
# URL 解析器测试
|
||||
add_executable(test_url_parser
|
||||
unit/test_url_parser.cpp
|
||||
)
|
||||
target_link_libraries(test_url_parser PRIVATE
|
||||
tut_lib
|
||||
GTest::gtest_main
|
||||
)
|
||||
add_test(NAME UrlParserTest COMMAND test_url_parser)
|
||||
|
||||
# HTTP 客户端测试
|
||||
add_executable(test_http_client
|
||||
unit/test_http_client.cpp
|
||||
)
|
||||
target_link_libraries(test_http_client PRIVATE
|
||||
tut_lib
|
||||
GTest::gtest_main
|
||||
)
|
||||
add_test(NAME HttpClientTest COMMAND test_http_client)
|
||||
|
||||
# HTML 渲染器测试
|
||||
add_executable(test_html_renderer
|
||||
unit/test_html_renderer.cpp
|
||||
)
|
||||
target_link_libraries(test_html_renderer PRIVATE
|
||||
tut_lib
|
||||
GTest::gtest_main
|
||||
)
|
||||
add_test(NAME HtmlRendererTest COMMAND test_html_renderer)
|
||||
|
||||
# ============================================================================
|
||||
# 集成测试
|
||||
# ============================================================================
|
||||
|
||||
add_executable(test_browser_engine
|
||||
integration/test_browser_engine.cpp
|
||||
)
|
||||
target_link_libraries(test_browser_engine PRIVATE
|
||||
tut_lib
|
||||
GTest::gtest_main
|
||||
)
|
||||
add_test(NAME BrowserEngineTest COMMAND test_browser_engine)
|
||||
|
||||
# ============================================================================
|
||||
# 测试发现
|
||||
# ============================================================================
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(test_url_parser)
|
||||
gtest_discover_tests(test_http_client)
|
||||
gtest_discover_tests(test_html_renderer)
|
||||
gtest_discover_tests(test_browser_engine)
|
||||
84
tests/integration/test_browser_engine.cpp
Normal file
84
tests/integration/test_browser_engine.cpp
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* @file test_browser_engine.cpp
|
||||
* @brief 浏览器引擎集成测试
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include "core/browser_engine.hpp"
|
||||
|
||||
namespace tut {
|
||||
namespace test {
|
||||
|
||||
class BrowserEngineTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
engine_ = std::make_unique<BrowserEngine>();
|
||||
}
|
||||
|
||||
std::unique_ptr<BrowserEngine> engine_;
|
||||
};
|
||||
|
||||
TEST_F(BrowserEngineTest, LoadSimpleHtmlPage) {
|
||||
const std::string html = R"(
|
||||
<html>
|
||||
<head><title>Test Page</title></head>
|
||||
<body><h1>Hello World</h1></body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
ASSERT_TRUE(engine_->loadHtml(html));
|
||||
// 标题提取需要完整实现后测试
|
||||
// EXPECT_EQ(engine_->getTitle(), "Test Page");
|
||||
}
|
||||
|
||||
TEST_F(BrowserEngineTest, ExtractLinks) {
|
||||
const std::string html = R"(
|
||||
<html>
|
||||
<body>
|
||||
<a href="/page1">Link 1</a>
|
||||
<a href="https://example.com">Link 2</a>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
engine_->loadHtml(html);
|
||||
auto links = engine_->extractLinks();
|
||||
// 链接提取需要完整实现后测试
|
||||
// EXPECT_EQ(links.size(), 2);
|
||||
}
|
||||
|
||||
TEST_F(BrowserEngineTest, NavigationHistory) {
|
||||
// 初始状态不能后退或前进
|
||||
EXPECT_FALSE(engine_->canGoBack());
|
||||
EXPECT_FALSE(engine_->canGoForward());
|
||||
|
||||
// 加载页面后...
|
||||
engine_->loadUrl("https://example.com/page1");
|
||||
engine_->loadUrl("https://example.com/page2");
|
||||
|
||||
// 可以后退
|
||||
// EXPECT_TRUE(engine_->canGoBack());
|
||||
// EXPECT_FALSE(engine_->canGoForward());
|
||||
}
|
||||
|
||||
TEST_F(BrowserEngineTest, Refresh) {
|
||||
engine_->loadUrl("https://example.com");
|
||||
EXPECT_TRUE(engine_->refresh());
|
||||
}
|
||||
|
||||
TEST_F(BrowserEngineTest, GetRenderedContent) {
|
||||
const std::string html = "<p>Test content</p>";
|
||||
engine_->loadHtml(html);
|
||||
|
||||
std::string content = engine_->getRenderedContent();
|
||||
// 应该包含原始内容
|
||||
EXPECT_NE(content.find("Test content"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(BrowserEngineTest, GetCurrentUrl) {
|
||||
engine_->loadUrl("https://example.com/test");
|
||||
EXPECT_EQ(engine_->getCurrentUrl(), "https://example.com/test");
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace tut
|
||||
|
|
@ -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;
|
||||
}
|
||||
124
tests/unit/test_html_renderer.cpp
Normal file
124
tests/unit/test_html_renderer.cpp
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* @file test_html_renderer.cpp
|
||||
* @brief HTML 渲染器单元测试
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include "renderer/html_renderer.hpp"
|
||||
|
||||
namespace tut {
|
||||
namespace test {
|
||||
|
||||
class HtmlRendererTest : public ::testing::Test {
|
||||
protected:
|
||||
HtmlRenderer renderer_;
|
||||
};
|
||||
|
||||
TEST_F(HtmlRendererTest, ExtractTitle) {
|
||||
const std::string html = R"(
|
||||
<html>
|
||||
<head><title>Test Page</title></head>
|
||||
<body><h1>Hello</h1></body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
EXPECT_EQ(renderer_.extractTitle(html), "Test Page");
|
||||
}
|
||||
|
||||
TEST_F(HtmlRendererTest, ExtractTitleMissing) {
|
||||
const std::string html = "<html><body>No title</body></html>";
|
||||
EXPECT_EQ(renderer_.extractTitle(html), "");
|
||||
}
|
||||
|
||||
TEST_F(HtmlRendererTest, RenderSimpleParagraph) {
|
||||
const std::string html = "<p>Hello World</p>";
|
||||
auto result = renderer_.render(html);
|
||||
EXPECT_FALSE(result.text.empty());
|
||||
EXPECT_NE(result.text.find("Hello World"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(HtmlRendererTest, ExtractLinks) {
|
||||
const std::string html = R"(
|
||||
<html>
|
||||
<body>
|
||||
<a href="https://example.com">Link 1</a>
|
||||
<a href="/relative">Link 2</a>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
auto links = renderer_.extractLinks(html);
|
||||
EXPECT_EQ(links.size(), 2);
|
||||
}
|
||||
|
||||
TEST_F(HtmlRendererTest, ResolveRelativeLinks) {
|
||||
const std::string html = R"(
|
||||
<html>
|
||||
<body>
|
||||
<a href="/page.html">Link</a>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
auto links = renderer_.extractLinks(html, "https://example.com/dir/");
|
||||
ASSERT_EQ(links.size(), 1);
|
||||
EXPECT_EQ(links[0].url, "https://example.com/page.html");
|
||||
}
|
||||
|
||||
TEST_F(HtmlRendererTest, SkipScriptAndStyle) {
|
||||
const std::string html = R"(
|
||||
<html>
|
||||
<head>
|
||||
<style>body { color: red; }</style>
|
||||
<script>alert('hello');</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>Visible content</p>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
auto result = renderer_.render(html);
|
||||
EXPECT_NE(result.text.find("Visible content"), std::string::npos);
|
||||
EXPECT_EQ(result.text.find("alert"), std::string::npos);
|
||||
EXPECT_EQ(result.text.find("color: red"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(HtmlRendererTest, RenderHeadings) {
|
||||
const std::string html = R"(
|
||||
<h1>Heading 1</h1>
|
||||
<h2>Heading 2</h2>
|
||||
<p>Paragraph</p>
|
||||
)";
|
||||
|
||||
auto result = renderer_.render(html);
|
||||
EXPECT_NE(result.text.find("Heading 1"), std::string::npos);
|
||||
EXPECT_NE(result.text.find("Heading 2"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(HtmlRendererTest, RenderList) {
|
||||
const std::string html = R"(
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
)";
|
||||
|
||||
auto result = renderer_.render(html);
|
||||
EXPECT_NE(result.text.find("Item 1"), std::string::npos);
|
||||
EXPECT_NE(result.text.find("Item 2"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(HtmlRendererTest, RenderWithoutColors) {
|
||||
const std::string html = "<a href=\"#\">Link</a>";
|
||||
|
||||
RenderOptions options;
|
||||
options.use_colors = false;
|
||||
|
||||
auto result = renderer_.render(html, options);
|
||||
// 不应该包含 ANSI 转义码
|
||||
EXPECT_EQ(result.text.find("\033["), std::string::npos);
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace tut
|
||||
79
tests/unit/test_http_client.cpp
Normal file
79
tests/unit/test_http_client.cpp
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* @file test_http_client.cpp
|
||||
* @brief HTTP 客户端单元测试
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include "core/http_client.hpp"
|
||||
|
||||
namespace tut {
|
||||
namespace test {
|
||||
|
||||
class HttpClientTest : public ::testing::Test {
|
||||
protected:
|
||||
HttpClient client_;
|
||||
};
|
||||
|
||||
TEST_F(HttpClientTest, GetRequest) {
|
||||
auto response = client_.get("https://httpbin.org/get");
|
||||
EXPECT_TRUE(response.isSuccess());
|
||||
EXPECT_EQ(response.status_code, 200);
|
||||
EXPECT_FALSE(response.body.empty());
|
||||
}
|
||||
|
||||
TEST_F(HttpClientTest, PostRequest) {
|
||||
auto response = client_.post(
|
||||
"https://httpbin.org/post",
|
||||
"key=value",
|
||||
"application/x-www-form-urlencoded"
|
||||
);
|
||||
EXPECT_TRUE(response.isSuccess());
|
||||
EXPECT_EQ(response.status_code, 200);
|
||||
}
|
||||
|
||||
TEST_F(HttpClientTest, HeadRequest) {
|
||||
auto response = client_.head("https://httpbin.org/get");
|
||||
EXPECT_TRUE(response.isSuccess());
|
||||
EXPECT_TRUE(response.body.empty()); // HEAD 请求没有 body
|
||||
}
|
||||
|
||||
TEST_F(HttpClientTest, InvalidUrl) {
|
||||
auto response = client_.get("https://invalid.invalid.invalid/");
|
||||
EXPECT_TRUE(response.isError());
|
||||
}
|
||||
|
||||
TEST_F(HttpClientTest, TimeoutConfig) {
|
||||
HttpConfig config;
|
||||
config.timeout_seconds = 5;
|
||||
client_.setConfig(config);
|
||||
|
||||
EXPECT_EQ(client_.getConfig().timeout_seconds, 5);
|
||||
}
|
||||
|
||||
TEST_F(HttpClientTest, CookieManagement) {
|
||||
client_.setCookie("example.com", "session", "abc123");
|
||||
|
||||
auto cookie = client_.getCookie("example.com", "session");
|
||||
ASSERT_TRUE(cookie.has_value());
|
||||
EXPECT_EQ(*cookie, "abc123");
|
||||
|
||||
client_.clearCookies();
|
||||
cookie = client_.getCookie("example.com", "session");
|
||||
EXPECT_FALSE(cookie.has_value());
|
||||
}
|
||||
|
||||
TEST_F(HttpClientTest, Redirect) {
|
||||
// httpbin.org/redirect/n redirects n times then returns 200
|
||||
auto response = client_.get("https://httpbin.org/redirect/1");
|
||||
EXPECT_TRUE(response.isSuccess());
|
||||
EXPECT_EQ(response.status_code, 200);
|
||||
}
|
||||
|
||||
TEST_F(HttpClientTest, NotFound) {
|
||||
auto response = client_.get("https://httpbin.org/status/404");
|
||||
EXPECT_FALSE(response.isSuccess());
|
||||
EXPECT_EQ(response.status_code, 404);
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace tut
|
||||
89
tests/unit/test_url_parser.cpp
Normal file
89
tests/unit/test_url_parser.cpp
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* @file test_url_parser.cpp
|
||||
* @brief URL 解析器单元测试
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include "core/url_parser.hpp"
|
||||
|
||||
namespace tut {
|
||||
namespace test {
|
||||
|
||||
class UrlParserTest : public ::testing::Test {
|
||||
protected:
|
||||
UrlParser parser_;
|
||||
};
|
||||
|
||||
TEST_F(UrlParserTest, ParseValidHttpUrl) {
|
||||
auto result = parser_.parse("https://example.com:8080/path?query=1");
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ(result->scheme, "https");
|
||||
EXPECT_EQ(result->host, "example.com");
|
||||
EXPECT_EQ(result->port, 8080);
|
||||
EXPECT_EQ(result->path, "/path");
|
||||
EXPECT_EQ(result->query, "query=1");
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, ParseUrlWithoutPort) {
|
||||
auto result = parser_.parse("http://example.com/path");
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ(result->port, 80); // 默认 HTTP 端口
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, ParseHttpsDefaultPort) {
|
||||
auto result = parser_.parse("https://example.com/path");
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ(result->port, 443); // 默认 HTTPS 端口
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, ParseInvalidUrl) {
|
||||
auto result = parser_.parse("not a url");
|
||||
// 这个测试取决于我们如何定义 "无效"
|
||||
// 当前实现可能仍会尝试解析
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, ParseEmptyUrl) {
|
||||
auto result = parser_.parse("");
|
||||
EXPECT_FALSE(result.has_value());
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, ParseUrlWithFragment) {
|
||||
auto result = parser_.parse("https://example.com#section");
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ(result->fragment, "section");
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, ParseUrlWithUserInfo) {
|
||||
auto result = parser_.parse("https://user:pass@example.com/path");
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ(result->userinfo, "user:pass");
|
||||
EXPECT_EQ(result->host, "example.com");
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, ResolveRelativeUrl) {
|
||||
std::string base = "https://example.com/dir/page.html";
|
||||
EXPECT_EQ(parser_.resolveRelative(base, "other.html"),
|
||||
"https://example.com/dir/other.html");
|
||||
EXPECT_EQ(parser_.resolveRelative(base, "/absolute.html"),
|
||||
"https://example.com/absolute.html");
|
||||
EXPECT_EQ(parser_.resolveRelative(base, "//other.com/path"),
|
||||
"https://other.com/path");
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, NormalizeUrl) {
|
||||
EXPECT_EQ(parser_.normalize("https://example.com/a/../b/./c"),
|
||||
"https://example.com/b/c");
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, EncodeUrl) {
|
||||
EXPECT_EQ(UrlParser::encode("hello world"), "hello%20world");
|
||||
EXPECT_EQ(UrlParser::encode("a+b=c"), "a%2Bb%3Dc");
|
||||
}
|
||||
|
||||
TEST_F(UrlParserTest, DecodeUrl) {
|
||||
EXPECT_EQ(UrlParser::decode("hello%20world"), "hello world");
|
||||
EXPECT_EQ(UrlParser::decode("a+b"), "a b");
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace tut
|
||||
Loading…
Reference in a new issue