diff --git a/.gitignore b/.gitignore index 26d8632..af4959f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Build artifacts build/ +build_ftxui/ +build_*/ *.o *.a *.so diff --git a/CMakeLists.txt b/CMakeLists.txt index 65307b7..a0fecae 100644 --- a/CMakeLists.txt +++ b/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 "") diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e654012 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/assets/config.toml b/assets/config.toml new file mode 100644 index 0000000..d933621 --- /dev/null +++ b/assets/config.toml @@ -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" diff --git a/assets/keybindings/default.toml b/assets/keybindings/default.toml new file mode 100644 index 0000000..27e091e --- /dev/null +++ b/assets/keybindings/default.toml @@ -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"] diff --git a/assets/themes/default.toml b/assets/themes/default.toml new file mode 100644 index 0000000..02a2635 --- /dev/null +++ b/assets/themes/default.toml @@ -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 diff --git a/assets/themes/gruvbox.toml b/assets/themes/gruvbox.toml new file mode 100644 index 0000000..ac54311 --- /dev/null +++ b/assets/themes/gruvbox.toml @@ -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 diff --git a/assets/themes/nord.toml b/assets/themes/nord.toml new file mode 100644 index 0000000..7560f9c --- /dev/null +++ b/assets/themes/nord.toml @@ -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 diff --git a/assets/themes/solarized-dark.toml b/assets/themes/solarized-dark.toml new file mode 100644 index 0000000..4798d6f --- /dev/null +++ b/assets/themes/solarized-dark.toml @@ -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 diff --git a/cmake/version.hpp.in b/cmake/version.hpp.in new file mode 100644 index 0000000..e1a3a54 --- /dev/null +++ b/cmake/version.hpp.in @@ -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 diff --git a/src/core/browser_engine.cpp b/src/core/browser_engine.cpp new file mode 100644 index 0000000..7b4e55f --- /dev/null +++ b/src/core/browser_engine.cpp @@ -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 links_; + std::vector history_; + size_t history_index_{0}; +}; + +BrowserEngine::BrowserEngine() : impl_(std::make_unique()) {} + +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 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 diff --git a/src/core/browser_engine.hpp b/src/core/browser_engine.hpp new file mode 100644 index 0000000..fd80c6f --- /dev/null +++ b/src/core/browser_engine.hpp @@ -0,0 +1,116 @@ +/** + * @file browser_engine.hpp + * @brief 浏览器引擎核心模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include +#include + +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 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_; +}; + +} // namespace tut diff --git a/src/core/http_client.cpp b/src/core/http_client.cpp new file mode 100644 index 0000000..26df4ad --- /dev/null +++ b/src/core/http_client.cpp @@ -0,0 +1,182 @@ +/** + * @file http_client.cpp + * @brief HTTP 客户端实现 + */ + +#include "core/http_client.hpp" + +// cpp-httplib HTTPS 支持已通过 CMake 启用 +#include + +#include + +namespace tut { + +class HttpClient::Impl { +public: + HttpConfig config; + std::map> cookies; + + HttpResponse makeRequest(const std::string& url, + const std::string& method, + const std::string& body = "", + const std::string& content_type = "", + const std::map& 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 client; + if (scheme == "https") { + client = std::make_unique("https://" + host_port); + } else { + client = std::make_unique("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(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_->config = config; +} + +HttpClient::~HttpClient() = default; + +HttpResponse HttpClient::get(const std::string& url, + const std::map& 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& 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 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 diff --git a/src/core/http_client.hpp b/src/core/http_client.hpp new file mode 100644 index 0000000..8f19053 --- /dev/null +++ b/src/core/http_client.hpp @@ -0,0 +1,160 @@ +/** + * @file http_client.hpp + * @brief HTTP 客户端模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace tut { + +/** + * @brief HTTP 响应结构体 + */ +struct HttpResponse { + int status_code{0}; ///< HTTP 状态码 + std::string status_text; ///< 状态文本 + std::map 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; + +/** + * @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& 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& 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 getCookie(const std::string& domain, + const std::string& name) const; + + /** + * @brief 清除所有 Cookie + */ + void clearCookies(); + +private: + class Impl; + std::unique_ptr impl_; +}; + +} // namespace tut diff --git a/src/core/url_parser.cpp b/src/core/url_parser.cpp new file mode 100644 index 0000000..8f08a4d --- /dev/null +++ b/src/core/url_parser.cpp @@ -0,0 +1,236 @@ +/** + * @file url_parser.cpp + * @brief URL 解析器实现 + */ + +#include "core/url_parser.hpp" +#include +#include +#include +#include + +namespace tut { + +std::optional 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(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 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(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(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 diff --git a/src/core/url_parser.hpp b/src/core/url_parser.hpp new file mode 100644 index 0000000..083e34f --- /dev/null +++ b/src/core/url_parser.hpp @@ -0,0 +1,103 @@ +/** + * @file url_parser.hpp + * @brief URL 解析器模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include + +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 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 diff --git a/src/main.cpp b/src/main.cpp index a72a554..c62cacc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,46 +1,182 @@ -#include "browser.h" +/** + * @file main.cpp + * @brief TUT 程序入口 + * @author m1ngsama + * @date 2024-12-29 + */ + #include +#include #include -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; } diff --git a/src/renderer/html_renderer.cpp b/src/renderer/html_renderer.cpp new file mode 100644 index 0000000..d8061ce --- /dev/null +++ b/src/renderer/html_renderer.cpp @@ -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 +#include + +namespace tut { + +class HtmlRenderer::Impl { +public: + RenderOptions options_; + + void renderNode(GumboNode* node, std::ostringstream& output, + std::vector& 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(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(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(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(node->v.element.children.data[i])); + if (!title.empty()) { + return title; + } + } + + return ""; + } +}; + +HtmlRenderer::HtmlRenderer() : impl_(std::make_unique()) {} + +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 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 diff --git a/src/renderer/html_renderer.hpp b/src/renderer/html_renderer.hpp new file mode 100644 index 0000000..4f2c926 --- /dev/null +++ b/src/renderer/html_renderer.hpp @@ -0,0 +1,96 @@ +/** + * @file html_renderer.hpp + * @brief HTML 渲染器模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include + +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 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 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_; +}; + +} // namespace tut diff --git a/src/renderer/style_parser.cpp b/src/renderer/style_parser.cpp new file mode 100644 index 0000000..40e3451 --- /dev/null +++ b/src/renderer/style_parser.cpp @@ -0,0 +1,209 @@ +/** + * @file style_parser.cpp + * @brief 样式解析实现 + */ + +#include "renderer/style_parser.hpp" +#include +#include +#include +#include + +namespace tut { + +const std::map 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::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(std::stoi(h.substr(0, 2), nullptr, 16)); + color.g = static_cast(std::stoi(h.substr(2, 2), nullptr, 16)); + color.b = static_cast(std::stoi(h.substr(4, 2), nullptr, 16)); + if (h.length() == 8) { + color.a = static_cast(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(r); + oss << std::setw(2) << std::setfill('0') << static_cast(g); + oss << std::setw(2) << std::setfill('0') << static_cast(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(std::round((r - 8.0) / 247.0 * 24)) + 232; + } + + // RGB 色 + int ri = static_cast(std::round(r / 255.0 * 5)); + int gi = static_cast(std::round(g / 255.0 * 5)); + int bi = static_cast(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(r) << ";" + << static_cast(g) << ";" + << static_cast(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 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 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(std::clamp(components[0], 0, 255)); + color.g = static_cast(std::clamp(components[1], 0, 255)); + color.b = static_cast(std::clamp(components[2], 0, 255)); + return color; + } + } + + // 命名颜色 + return getNamedColor(value); +} + +std::optional 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 diff --git a/src/renderer/style_parser.hpp b/src/renderer/style_parser.hpp new file mode 100644 index 0000000..8feb3fa --- /dev/null +++ b/src/renderer/style_parser.hpp @@ -0,0 +1,106 @@ +/** + * @file style_parser.hpp + * @brief 样式解析模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include + +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 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 foreground; + std::optional 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 parseColor(const std::string& value); + + /** + * @brief 获取命名颜色 + * @param name 颜色名称 + * @return 颜色值 + */ + static std::optional getNamedColor(const std::string& name); + +private: + static const std::map named_colors_; +}; + +} // namespace tut diff --git a/src/renderer/text_formatter.cpp b/src/renderer/text_formatter.cpp new file mode 100644 index 0000000..a246696 --- /dev/null +++ b/src/renderer/text_formatter.cpp @@ -0,0 +1,152 @@ +/** + * @file text_formatter.cpp + * @brief 文本格式化实现 + */ + +#include "renderer/text_formatter.hpp" +#include +#include + +namespace tut { + +std::vector TextFormatter::wrapText(const std::string& text, int width) { + std::vector 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(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(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(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(text.length()) >= width) { + return text; + } + int padding = width - static_cast(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(c))) { + if (!last_was_space) { + result += ' '; + last_was_space = true; + } + } else { + result += c; + last_was_space = false; + } + } + + return trim(result); +} + +} // namespace tut diff --git a/src/renderer/text_formatter.hpp b/src/renderer/text_formatter.hpp new file mode 100644 index 0000000..4c9963f --- /dev/null +++ b/src/renderer/text_formatter.hpp @@ -0,0 +1,94 @@ +/** + * @file text_formatter.hpp + * @brief 文本格式化模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include + +namespace tut { + +/** + * @brief 文本格式化器类 + * + * 负责文本的格式化、换行、对齐等操作 + */ +class TextFormatter { +public: + /** + * @brief 自动换行 + * @param text 输入文本 + * @param width 每行最大宽度 + * @return 换行后的文本行 + */ + static std::vector 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 diff --git a/src/ui/address_bar.cpp b/src/ui/address_bar.cpp new file mode 100644 index 0000000..5233afe --- /dev/null +++ b/src/ui/address_bar.cpp @@ -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 history_; + std::function on_submit_; + bool focused_{false}; +}; + +AddressBar::AddressBar() : impl_(std::make_unique()) {} + +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& history) { + impl_->history_ = history; +} + +void AddressBar::onSubmit(std::function 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 diff --git a/src/ui/address_bar.hpp b/src/ui/address_bar.hpp new file mode 100644 index 0000000..a63d3c4 --- /dev/null +++ b/src/ui/address_bar.hpp @@ -0,0 +1,65 @@ +/** + * @file address_bar.hpp + * @brief 地址栏组件 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include +#include + +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& history); + + /** + * @brief 注册 URL 提交回调 + */ + void onSubmit(std::function callback); + + /** + * @brief 聚焦地址栏 + */ + void focus(); + + /** + * @brief 取消聚焦 + */ + void blur(); + + /** + * @brief 是否处于聚焦状态 + */ + bool isFocused() const; + +private: + class Impl; + std::unique_ptr impl_; +}; + +} // namespace tut diff --git a/src/ui/bookmark_panel.cpp b/src/ui/bookmark_panel.cpp new file mode 100644 index 0000000..71fe930 --- /dev/null +++ b/src/ui/bookmark_panel.cpp @@ -0,0 +1,93 @@ +/** + * @file bookmark_panel.cpp + * @brief 书签面板组件实现 + */ + +#include "ui/bookmark_panel.hpp" +#include + +namespace tut { + +class BookmarkPanel::Impl { +public: + std::vector bookmarks_; + int selected_index_{0}; + bool visible_{false}; + std::function on_select_; +}; + +BookmarkPanel::BookmarkPanel() : impl_(std::make_unique()) {} + +BookmarkPanel::~BookmarkPanel() = default; + +void BookmarkPanel::setBookmarks(const std::vector& bookmarks) { + impl_->bookmarks_ = bookmarks; + impl_->selected_index_ = bookmarks.empty() ? -1 : 0; +} + +std::vector 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(impl_->bookmarks_.size()); +} + +void BookmarkPanel::selectPrevious() { + if (impl_->bookmarks_.empty()) return; + impl_->selected_index_--; + if (impl_->selected_index_ < 0) { + impl_->selected_index_ = static_cast(impl_->bookmarks_.size()) - 1; + } +} + +int BookmarkPanel::getSelectedIndex() const { + return impl_->selected_index_; +} + +void BookmarkPanel::onSelect(std::function callback) { + impl_->on_select_ = std::move(callback); +} + +std::vector BookmarkPanel::search(const std::string& query) const { + std::vector 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 diff --git a/src/ui/bookmark_panel.hpp b/src/ui/bookmark_panel.hpp new file mode 100644 index 0000000..1384a39 --- /dev/null +++ b/src/ui/bookmark_panel.hpp @@ -0,0 +1,98 @@ +/** + * @file bookmark_panel.hpp + * @brief 书签面板组件 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include +#include + +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& bookmarks); + + /** + * @brief 获取所有书签 + */ + std::vector 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 callback); + + /** + * @brief 搜索书签 + */ + std::vector search(const std::string& query) const; + + /** + * @brief 显示/隐藏面板 + */ + void setVisible(bool visible); + + /** + * @brief 是否可见 + */ + bool isVisible() const; + +private: + class Impl; + std::unique_ptr impl_; +}; + +} // namespace tut diff --git a/src/ui/content_view.cpp b/src/ui/content_view.cpp new file mode 100644 index 0000000..d3f6cbe --- /dev/null +++ b/src/ui/content_view.cpp @@ -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 links_; + int scroll_position_{0}; + int selected_link_{-1}; + std::string search_query_; + std::vector search_results_; + int current_search_result_{-1}; + std::function on_link_activate_; +}; + +ContentView::ContentView() : impl_(std::make_unique()) {} + +ContentView::~ContentView() = default; + +void ContentView::setContent(const std::string& content) { + impl_->content_ = content; + impl_->scroll_position_ = 0; +} + +void ContentView::setLinks(const std::vector& 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(impl_->links_.size()); +} + +void ContentView::selectPreviousLink() { + if (impl_->links_.empty()) return; + impl_->selected_link_--; + if (impl_->selected_link_ < 0) { + impl_->selected_link_ = static_cast(impl_->links_.size()) - 1; + } +} + +int ContentView::getSelectedLinkIndex() const { + return impl_->selected_link_; +} + +void ContentView::onLinkActivate(std::function 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(impl_->search_results_.size()); +} + +void ContentView::nextSearchResult() { + if (impl_->search_results_.empty()) return; + impl_->current_search_result_ = + (impl_->current_search_result_ + 1) % static_cast(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(impl_->search_results_.size()) - 1; + } +} + +void ContentView::clearSearch() { + impl_->search_query_.clear(); + impl_->search_results_.clear(); + impl_->current_search_result_ = -1; +} + +} // namespace tut diff --git a/src/ui/content_view.hpp b/src/ui/content_view.hpp new file mode 100644 index 0000000..6e36da4 --- /dev/null +++ b/src/ui/content_view.hpp @@ -0,0 +1,120 @@ +/** + * @file content_view.hpp + * @brief 内容视图组件 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include +#include + +namespace tut { + +struct LinkInfo; + +/** + * @brief 内容视图组件类 + * + * 负责显示渲染后的网页内容 + */ +class ContentView { +public: + ContentView(); + ~ContentView(); + + /** + * @brief 设置内容 + */ + void setContent(const std::string& content); + + /** + * @brief 设置链接列表 + */ + void setLinks(const std::vector& 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 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_; +}; + +} // namespace tut diff --git a/src/ui/main_window.cpp b/src/ui/main_window.cpp new file mode 100644 index 0000000..d9e546c --- /dev/null +++ b/src/ui/main_window.cpp @@ -0,0 +1,161 @@ +/** + * @file main_window.cpp + * @brief 主窗口实现 + */ + +#include "ui/main_window.hpp" + +#include +#include +#include + +namespace tut { + +class MainWindow::Impl { +public: + std::string url_; + std::string title_; + std::string content_; + std::string status_message_; + bool loading_{false}; + + std::function on_navigate_; + std::function on_event_; +}; + +MainWindow::MainWindow() : impl_(std::make_unique()) {} + +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 callback) { + impl_->on_navigate_ = std::move(callback); +} + +void MainWindow::onEvent(std::function callback) { + impl_->on_event_ = std::move(callback); +} + +} // namespace tut diff --git a/src/ui/main_window.hpp b/src/ui/main_window.hpp new file mode 100644 index 0000000..14beb50 --- /dev/null +++ b/src/ui/main_window.hpp @@ -0,0 +1,123 @@ +/** + * @file main_window.hpp + * @brief 主窗口模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include +#include + +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& links); + + /** + * @brief 设置书签列表 + */ + void setBookmarks(const std::vector& bookmarks); + + /** + * @brief 设置历史记录列表 + */ + void setHistory(const std::vector& 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 callback); + void onEvent(std::function callback); + void onLinkClick(std::function callback); + void onBookmarkClick(std::function callback); + +private: + class Impl; + std::unique_ptr impl_; +}; + +} // namespace tut diff --git a/src/ui/status_bar.cpp b/src/ui/status_bar.cpp new file mode 100644 index 0000000..2c731e4 --- /dev/null +++ b/src/ui/status_bar.cpp @@ -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()) {} + +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 diff --git a/src/ui/status_bar.hpp b/src/ui/status_bar.hpp new file mode 100644 index 0000000..816fbd3 --- /dev/null +++ b/src/ui/status_bar.hpp @@ -0,0 +1,74 @@ +/** + * @file status_bar.hpp + * @brief 状态栏组件 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include + +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_; +}; + +} // namespace tut diff --git a/src/utils/config.cpp b/src/utils/config.cpp new file mode 100644 index 0000000..46c08db --- /dev/null +++ b/src/utils/config.cpp @@ -0,0 +1,219 @@ +/** + * @file config.cpp + * @brief 配置管理实现 + */ + +#include "utils/config.hpp" +#include "utils/logger.hpp" + +#include +#include +#include + +namespace fs = std::filesystem; + +namespace tut { + +class Config::Impl { +public: + std::string config_path_; + toml::value config_; + std::map string_values_; + std::map int_values_; + std::map bool_values_; + std::map double_values_; +}; + +Config& Config::instance() { + static Config instance; + return instance; +} + +Config::Config() : impl_(std::make_unique()) { + 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(general, "home_page"); + } + if (general.contains("default_theme")) { + impl_->string_values_["general.default_theme"] = + toml::find(general, "default_theme"); + } + } + + if (impl_->config_.contains("network")) { + auto& network = impl_->config_["network"]; + if (network.contains("timeout")) { + impl_->int_values_["network.timeout"] = + toml::find(network, "timeout"); + } + if (network.contains("max_redirects")) { + impl_->int_values_["network.max_redirects"] = + toml::find(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(rendering, "show_images"); + } + if (rendering.contains("javascript_enabled")) { + impl_->bool_values_["rendering.javascript_enabled"] = + toml::find(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 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 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 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 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 diff --git a/src/utils/config.hpp b/src/utils/config.hpp new file mode 100644 index 0000000..68a7008 --- /dev/null +++ b/src/utils/config.hpp @@ -0,0 +1,114 @@ +/** + * @file config.hpp + * @brief 配置管理模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include +#include + +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 getString(const std::string& key) const; + + /** + * @brief 获取整数配置项 + */ + std::optional getInt(const std::string& key) const; + + /** + * @brief 获取布尔配置项 + */ + std::optional getBool(const std::string& key) const; + + /** + * @brief 获取浮点配置项 + */ + std::optional 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_; +}; + +} // namespace tut diff --git a/src/utils/logger.cpp b/src/utils/logger.cpp new file mode 100644 index 0000000..316a95b --- /dev/null +++ b/src/utils/logger.cpp @@ -0,0 +1,123 @@ +/** + * @file logger.cpp + * @brief 日志系统实现 + */ + +#include "utils/logger.hpp" +#include +#include +#include +#include + +namespace tut { + +Logger& Logger::instance() { + static Logger instance; + return instance; +} + +Logger::Logger() = default; + +Logger::~Logger() { + flush(); +} + +void Logger::setLevel(LogLevel level) { + std::lock_guard lock(mutex_); + level_ = level; +} + +LogLevel Logger::getLevel() const { + std::lock_guard lock(mutex_); + return level_; +} + +bool Logger::setFile(const std::string& filepath) { + std::lock_guard 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 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 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 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( + 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 diff --git a/src/utils/logger.hpp b/src/utils/logger.hpp new file mode 100644 index 0000000..ae9e528 --- /dev/null +++ b/src/utils/logger.hpp @@ -0,0 +1,124 @@ +/** + * @file logger.hpp + * @brief 日志系统模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include +#include +#include + +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 + 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 diff --git a/src/utils/theme.cpp b/src/utils/theme.cpp new file mode 100644 index 0000000..7041822 --- /dev/null +++ b/src/utils/theme.cpp @@ -0,0 +1,207 @@ +/** + * @file theme.cpp + * @brief 主题管理实现 + */ + +#include "utils/theme.hpp" +#include "utils/logger.hpp" + +#include +#include +#include + +namespace fs = std::filesystem; + +namespace tut { + +class ThemeManager::Impl { +public: + std::map themes_; + std::string current_theme_name_{"default"}; + Theme current_theme_; + + bool parseColor(const toml::value& value, Color& color) { + try { + std::string hex = toml::get(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(ui, "border_style"); + } + if (ui.contains("show_shadows")) { + theme.show_shadows = toml::find(ui, "show_shadows"); + } + if (ui.contains("transparency")) { + theme.transparency = toml::find(ui, "transparency"); + } + } + + if (config.contains("meta")) { + auto& meta = config.at("meta"); + if (meta.contains("description")) { + theme.description = toml::find(meta, "description"); + } + } + + return theme; + } +}; + +ThemeManager& ThemeManager::instance() { + static ThemeManager instance; + return instance; +} + +ThemeManager::ThemeManager() : impl_(std::make_unique()) { + 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 ThemeManager::getThemeNames() const { + std::vector 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 diff --git a/src/utils/theme.hpp b/src/utils/theme.hpp new file mode 100644 index 0000000..33832ee --- /dev/null +++ b/src/utils/theme.hpp @@ -0,0 +1,115 @@ +/** + * @file theme.hpp + * @brief 主题管理模块 + * @author m1ngsama + * @date 2024-12-29 + */ + +#pragma once + +#include +#include +#include +#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 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_; +}; + +} // namespace tut diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..cdf34c8 --- /dev/null +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/integration/test_browser_engine.cpp b/tests/integration/test_browser_engine.cpp new file mode 100644 index 0000000..290d6d9 --- /dev/null +++ b/tests/integration/test_browser_engine.cpp @@ -0,0 +1,84 @@ +/** + * @file test_browser_engine.cpp + * @brief 浏览器引擎集成测试 + */ + +#include +#include "core/browser_engine.hpp" + +namespace tut { +namespace test { + +class BrowserEngineTest : public ::testing::Test { +protected: + void SetUp() override { + engine_ = std::make_unique(); + } + + std::unique_ptr engine_; +}; + +TEST_F(BrowserEngineTest, LoadSimpleHtmlPage) { + const std::string html = R"( + + Test Page +

Hello World

+ + )"; + + ASSERT_TRUE(engine_->loadHtml(html)); + // 标题提取需要完整实现后测试 + // EXPECT_EQ(engine_->getTitle(), "Test Page"); +} + +TEST_F(BrowserEngineTest, ExtractLinks) { + const std::string html = R"( + + + Link 1 + Link 2 + + + )"; + + 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 = "

Test content

"; + 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 diff --git a/tests/unit/test_html_renderer.cpp b/tests/unit/test_html_renderer.cpp new file mode 100644 index 0000000..8c93279 --- /dev/null +++ b/tests/unit/test_html_renderer.cpp @@ -0,0 +1,124 @@ +/** + * @file test_html_renderer.cpp + * @brief HTML 渲染器单元测试 + */ + +#include +#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"( + + Test Page +

Hello

+ + )"; + + EXPECT_EQ(renderer_.extractTitle(html), "Test Page"); +} + +TEST_F(HtmlRendererTest, ExtractTitleMissing) { + const std::string html = "No title"; + EXPECT_EQ(renderer_.extractTitle(html), ""); +} + +TEST_F(HtmlRendererTest, RenderSimpleParagraph) { + const std::string html = "

Hello World

"; + 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"( + + + Link 1 + Link 2 + + + )"; + + auto links = renderer_.extractLinks(html); + EXPECT_EQ(links.size(), 2); +} + +TEST_F(HtmlRendererTest, ResolveRelativeLinks) { + const std::string html = R"( + + + Link + + + )"; + + 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"( + + + + + + +

Visible content

+ + + )"; + + 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"( +

Heading 1

+

Heading 2

+

Paragraph

+ )"; + + 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"( +
    +
  • Item 1
  • +
  • Item 2
  • +
+ )"; + + 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 = "Link"; + + 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 diff --git a/tests/unit/test_http_client.cpp b/tests/unit/test_http_client.cpp new file mode 100644 index 0000000..7c94dde --- /dev/null +++ b/tests/unit/test_http_client.cpp @@ -0,0 +1,79 @@ +/** + * @file test_http_client.cpp + * @brief HTTP 客户端单元测试 + */ + +#include +#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 diff --git a/tests/unit/test_url_parser.cpp b/tests/unit/test_url_parser.cpp new file mode 100644 index 0000000..a4464eb --- /dev/null +++ b/tests/unit/test_url_parser.cpp @@ -0,0 +1,89 @@ +/** + * @file test_url_parser.cpp + * @brief URL 解析器单元测试 + */ + +#include +#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