mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 00:54:05 +00:00
feat: Complete FTXUI refactoring with clean architecture
Major architectural refactoring from ncurses to FTXUI framework with professional engineering structure. Project Structure: - src/core/: Browser engine, URL parser, HTTP client - src/ui/: FTXUI components (main window, address bar, content view, panels) - src/renderer/: HTML renderer, text formatter, style parser - src/utils/: Logger, config manager, theme manager - tests/unit/: Unit tests for core components - tests/integration/: Integration tests - assets/: Default configs, themes, keybindings New Features: - btop-style four-panel layout with rounded borders - TOML-based configuration system - Multiple color themes (default, nord, gruvbox, solarized) - Comprehensive logging system - Modular architecture with clear separation of concerns Build System: - Updated CMakeLists.txt for modular build - Prefer system packages (Homebrew) over FetchContent - Google Test integration for testing - Version info generation via cmake/version.hpp.in Configuration: - Default config.toml with browser settings - Four built-in themes - Default keybindings configuration - Config stored in ~/.config/tut/ Removed: - Legacy v1 source files (ncurses-based) - Old render/ directory - Duplicate and obsolete test files - Old documentation files Binary: ~827KB (well under 5MB goal) Dependencies: FTXUI, cpp-httplib, toml11, gumbo-parser, OpenSSL
This commit is contained in:
parent
70f20a370e
commit
6408f0e95c
44 changed files with 4727 additions and 160 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
|||
# Build artifacts
|
||||
build/
|
||||
build_ftxui/
|
||||
build_*/
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
|
|
|||
409
CMakeLists.txt
409
CMakeLists.txt
|
|
@ -1,171 +1,314 @@
|
|||
cmake_minimum_required(VERSION 3.15)
|
||||
project(TUT 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
|
||||
)
|
||||
|
||||
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/render
|
||||
${CMAKE_SOURCE_DIR}/src/utils
|
||||
${CURL_INCLUDE_DIRS}
|
||||
${CURSES_INCLUDE_DIRS}
|
||||
${CMAKE_SOURCE_DIR}/include
|
||||
${CMAKE_BINARY_DIR}/include
|
||||
${GUMBO_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
# ==================== TUT 主程序 ====================
|
||||
|
||||
add_executable(tut
|
||||
src/main.cpp
|
||||
src/browser.cpp
|
||||
src/http_client.cpp
|
||||
src/input_handler.cpp
|
||||
src/bookmark.cpp
|
||||
src/history.cpp
|
||||
src/render/terminal.cpp
|
||||
src/render/renderer.cpp
|
||||
src/render/layout.cpp
|
||||
src/render/image.cpp
|
||||
src/utils/unicode.cpp
|
||||
src/dom_tree.cpp
|
||||
src/html_parser.cpp
|
||||
)
|
||||
|
||||
target_link_directories(tut PRIVATE
|
||||
target_link_directories(tut_lib PUBLIC
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(tut
|
||||
${CURSES_LIBRARIES}
|
||||
CURL::libcurl
|
||||
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 可执行文件
|
||||
# ============================================================================
|
||||
add_executable(tut src/main.cpp)
|
||||
|
||||
# Terminal 测试
|
||||
add_executable(test_terminal
|
||||
src/render/terminal.cpp
|
||||
tests/test_terminal.cpp
|
||||
target_link_libraries(tut PRIVATE tut_lib)
|
||||
|
||||
# ============================================================================
|
||||
# 安装规则
|
||||
# ============================================================================
|
||||
include(GNUInstallDirs)
|
||||
|
||||
install(TARGETS tut
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||
)
|
||||
|
||||
target_link_libraries(test_terminal
|
||||
${CURSES_LIBRARIES}
|
||||
install(DIRECTORY assets/themes/
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/tut/themes
|
||||
OPTIONAL
|
||||
)
|
||||
|
||||
# Renderer 测试
|
||||
add_executable(test_renderer
|
||||
src/render/terminal.cpp
|
||||
src/render/renderer.cpp
|
||||
src/utils/unicode.cpp
|
||||
tests/test_renderer.cpp
|
||||
install(DIRECTORY assets/keybindings/
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/tut/keybindings
|
||||
OPTIONAL
|
||||
)
|
||||
|
||||
target_link_libraries(test_renderer
|
||||
${CURSES_LIBRARIES}
|
||||
)
|
||||
# ============================================================================
|
||||
# 测试
|
||||
# ============================================================================
|
||||
if(TUT_BUILD_TESTS)
|
||||
enable_testing()
|
||||
|
||||
# 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
|
||||
)
|
||||
# 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(test_layout PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
# 添加测试子目录
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
|
||||
target_link_libraries(test_layout
|
||||
${CURSES_LIBRARIES}
|
||||
${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")
|
||||
|
||||
# HTTP 异步测试
|
||||
add_executable(test_http_async
|
||||
src/http_client.cpp
|
||||
tests/test_http_async.cpp
|
||||
)
|
||||
# 打包格式
|
||||
set(CPACK_GENERATOR "TGZ;ZIP")
|
||||
if(APPLE)
|
||||
list(APPEND CPACK_GENERATOR "DragNDrop")
|
||||
elseif(UNIX)
|
||||
list(APPEND CPACK_GENERATOR "DEB;RPM")
|
||||
endif()
|
||||
|
||||
target_link_libraries(test_http_async
|
||||
CURL::libcurl
|
||||
)
|
||||
# Debian 包配置
|
||||
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libgumbo1, libssl1.1 | libssl3")
|
||||
set(CPACK_DEBIAN_PACKAGE_SECTION "web")
|
||||
|
||||
# HTML 解析测试
|
||||
add_executable(test_html_parse
|
||||
src/html_parser.cpp
|
||||
src/dom_tree.cpp
|
||||
tests/test_html_parse.cpp
|
||||
)
|
||||
# RPM 包配置
|
||||
set(CPACK_RPM_PACKAGE_REQUIRES "gumbo-parser, openssl-libs")
|
||||
set(CPACK_RPM_PACKAGE_GROUP "Applications/Internet")
|
||||
|
||||
target_link_directories(test_html_parse PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
include(CPack)
|
||||
|
||||
target_link_libraries(test_html_parse
|
||||
${GUMBO_LIBRARIES}
|
||||
)
|
||||
|
||||
# 书签测试
|
||||
add_executable(test_bookmark
|
||||
src/bookmark.cpp
|
||||
tests/test_bookmark.cpp
|
||||
)
|
||||
|
||||
# 历史记录测试
|
||||
add_executable(test_history
|
||||
src/history.cpp
|
||||
tests/test_history.cpp
|
||||
)
|
||||
|
||||
# 异步图片下载测试
|
||||
add_executable(test_async_images
|
||||
src/http_client.cpp
|
||||
tests/test_async_images.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(test_async_images
|
||||
CURL::libcurl
|
||||
)
|
||||
|
||||
# 简单图片测试
|
||||
add_executable(test_simple_image
|
||||
src/http_client.cpp
|
||||
tests/test_simple_image.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(test_simple_image
|
||||
CURL::libcurl
|
||||
)
|
||||
|
||||
# 最小图片测试
|
||||
add_executable(test_image_minimal
|
||||
src/http_client.cpp
|
||||
tests/test_image_minimal.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(test_image_minimal
|
||||
CURL::libcurl
|
||||
)
|
||||
# ============================================================================
|
||||
# 构建信息摘要
|
||||
# ============================================================================
|
||||
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 "")
|
||||
|
|
|
|||
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.
|
||||
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
|
||||
76
src/core/browser_engine.cpp
Normal file
76
src/core/browser_engine.cpp
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* @file browser_engine.cpp
|
||||
* @brief 浏览器引擎实现
|
||||
*/
|
||||
|
||||
#include "core/browser_engine.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};
|
||||
};
|
||||
|
||||
BrowserEngine::BrowserEngine() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
BrowserEngine::~BrowserEngine() = default;
|
||||
|
||||
bool BrowserEngine::loadUrl(const std::string& url) {
|
||||
// TODO: 实现 HTTP 请求和 HTML 解析
|
||||
impl_->current_url_ = url;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BrowserEngine::loadHtml(const std::string& html) {
|
||||
// TODO: 实现 HTML 解析
|
||||
impl_->content_ = html;
|
||||
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
|
||||
116
src/core/browser_engine.hpp
Normal file
116
src/core/browser_engine.hpp
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* @file browser_engine.hpp
|
||||
* @brief 浏览器引擎核心模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
namespace tut {
|
||||
|
||||
/**
|
||||
* @brief 链接信息结构体
|
||||
*/
|
||||
struct LinkInfo {
|
||||
std::string url; ///< 链接 URL
|
||||
std::string text; ///< 链接文本
|
||||
int line{0}; ///< 所在行号
|
||||
};
|
||||
|
||||
/**
|
||||
* @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
|
||||
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
|
||||
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
|
||||
190
src/main.cpp
190
src/main.cpp
|
|
@ -1,46 +1,182 @@
|
|||
#include "browser.h"
|
||||
/**
|
||||
* @file main.cpp
|
||||
* @brief TUT 程序入口
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
|
||||
void print_usage(const char* prog_name) {
|
||||
std::cout << "TUT - 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"
|
||||
#include "tut/version.hpp"
|
||||
#include "core/browser_engine.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"
|
||||
<< " / 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";
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
std::string initial_url;
|
||||
} // namespace
|
||||
|
||||
if (argc > 1) {
|
||||
if (std::strcmp(argv[1], "-h") == 0 || std::strcmp(argv[1], "--help") == 0) {
|
||||
print_usage(argv[0]);
|
||||
int main(int argc, char* argv[]) {
|
||||
using namespace tut;
|
||||
|
||||
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);
|
||||
// 创建浏览器引擎
|
||||
BrowserEngine engine;
|
||||
|
||||
// 创建主窗口
|
||||
MainWindow window;
|
||||
|
||||
// 设置导航回调
|
||||
window.onNavigate([&engine, &window](const std::string& url) {
|
||||
LOG_INFO << "Navigating to: " << url;
|
||||
window.setLoading(true);
|
||||
|
||||
if (engine.loadUrl(url)) {
|
||||
window.setTitle(engine.getTitle());
|
||||
window.setContent(engine.getRenderedContent());
|
||||
window.setUrl(url);
|
||||
window.setStatusMessage("Loaded: " + url);
|
||||
} else {
|
||||
window.setStatusMessage("Failed to load: " + url);
|
||||
}
|
||||
|
||||
window.setLoading(false);
|
||||
});
|
||||
|
||||
// 初始化窗口
|
||||
if (!window.init()) {
|
||||
LOG_FATAL << "Failed to initialize window";
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 加载初始 URL
|
||||
if (!initial_url.empty()) {
|
||||
engine.loadUrl(initial_url);
|
||||
window.setUrl(initial_url);
|
||||
window.setTitle(engine.getTitle());
|
||||
window.setContent(engine.getRenderedContent());
|
||||
} 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;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
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
|
||||
96
src/renderer/html_renderer.hpp
Normal file
96
src/renderer/html_renderer.hpp
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* @file html_renderer.hpp
|
||||
* @brief HTML 渲染器模块
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
struct LinkInfo;
|
||||
|
||||
/**
|
||||
* @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
|
||||
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
|
||||
120
src/ui/content_view.hpp
Normal file
120
src/ui/content_view.hpp
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* @file content_view.hpp
|
||||
* @brief 内容视图组件
|
||||
* @author m1ngsama
|
||||
* @date 2024-12-29
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
namespace tut {
|
||||
|
||||
struct LinkInfo;
|
||||
|
||||
/**
|
||||
* @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
|
||||
161
src/ui/main_window.cpp
Normal file
161
src/ui/main_window.cpp
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* @file main_window.cpp
|
||||
* @brief 主窗口实现
|
||||
*/
|
||||
|
||||
#include "ui/main_window.hpp"
|
||||
|
||||
#include <ftxui/component/component.hpp>
|
||||
#include <ftxui/component/screen_interactive.hpp>
|
||||
#include <ftxui/dom/elements.hpp>
|
||||
|
||||
namespace tut {
|
||||
|
||||
class MainWindow::Impl {
|
||||
public:
|
||||
std::string url_;
|
||||
std::string title_;
|
||||
std::string content_;
|
||||
std::string status_message_;
|
||||
bool loading_{false};
|
||||
|
||||
std::function<void(const std::string&)> on_navigate_;
|
||||
std::function<void(WindowEvent)> on_event_;
|
||||
};
|
||||
|
||||
MainWindow::MainWindow() : impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
MainWindow::~MainWindow() = default;
|
||||
|
||||
bool MainWindow::init() {
|
||||
// TODO: 初始化 FTXUI 组件
|
||||
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...");
|
||||
|
||||
// 内容区域
|
||||
auto content_renderer = Renderer([this] {
|
||||
return vbox({
|
||||
text(impl_->title_) | bold | center,
|
||||
separator(),
|
||||
paragraph(impl_->content_),
|
||||
}) | flex;
|
||||
});
|
||||
|
||||
// 状态栏
|
||||
auto status_renderer = Renderer([this] {
|
||||
std::string status = impl_->loading_ ? "Loading..." : impl_->status_message_;
|
||||
return text(status) | dim;
|
||||
});
|
||||
|
||||
// 主布局
|
||||
auto main_layout = Container::Vertical({
|
||||
address_input,
|
||||
content_renderer,
|
||||
status_renderer,
|
||||
});
|
||||
|
||||
auto main_renderer = Renderer(main_layout, [&] {
|
||||
return vbox({
|
||||
// 顶部栏
|
||||
hbox({
|
||||
text("[◀]") | bold,
|
||||
text(" "),
|
||||
text("[▶]") | bold,
|
||||
text(" "),
|
||||
text("[⟳]") | bold,
|
||||
text(" "),
|
||||
address_input->Render() | flex | border,
|
||||
text(" "),
|
||||
text("[⚙]") | bold,
|
||||
text(" "),
|
||||
text("[?]") | bold,
|
||||
}),
|
||||
separator(),
|
||||
// 内容区
|
||||
content_renderer->Render() | flex,
|
||||
separator(),
|
||||
// 底部面板
|
||||
hbox({
|
||||
vbox({
|
||||
text("📑 Bookmarks") | bold,
|
||||
text(" (empty)") | dim,
|
||||
}) | flex,
|
||||
separator(),
|
||||
vbox({
|
||||
text("📊 Status") | bold,
|
||||
text(" Ready") | dim,
|
||||
}) | flex,
|
||||
}),
|
||||
separator(),
|
||||
// 状态栏
|
||||
hbox({
|
||||
text("[F1]Help") | dim,
|
||||
text(" "),
|
||||
text("[F2]Bookmarks") | dim,
|
||||
text(" "),
|
||||
text("[F3]History") | dim,
|
||||
text(" "),
|
||||
text("[F10]Quit") | dim,
|
||||
filler(),
|
||||
status_renderer->Render(),
|
||||
}),
|
||||
}) | border;
|
||||
});
|
||||
|
||||
// 事件处理
|
||||
main_renderer |= CatchEvent([&](Event event) {
|
||||
if (event == Event::Escape || event == Event::Character('q')) {
|
||||
screen.ExitLoopClosure()();
|
||||
return true;
|
||||
}
|
||||
if (event == Event::Return) {
|
||||
if (impl_->on_navigate_) {
|
||||
impl_->on_navigate_(address_content);
|
||||
}
|
||||
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_->content_ = content;
|
||||
}
|
||||
|
||||
void MainWindow::setLoading(bool loading) {
|
||||
impl_->loading_ = loading;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
} // 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
|
||||
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
|
||||
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
|
||||
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