mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 09:04:04 +00:00
refactor: Consolidate v2 architecture into main codebase
- Merge browser_v2 implementation into browser.cpp - Remove deprecated files: browser_v2.cpp/h, main_v2.cpp, text_renderer.cpp/h - Simplify CMakeLists.txt to build single 'tut' executable - Remove test HTML files no longer needed - Add stb_image.h for image support
This commit is contained in:
parent
a469f79a1e
commit
2878b42d36
15 changed files with 9010 additions and 2452 deletions
101
CMakeLists.txt
101
CMakeLists.txt
|
|
@ -1,5 +1,5 @@
|
|||
cmake_minimum_required(VERSION 3.15)
|
||||
project(TUT_v2 VERSION 2.0.0 LANGUAGES CXX)
|
||||
project(TUT VERSION 2.0.0 LANGUAGES CXX)
|
||||
|
||||
# C++17标准
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
|
|
@ -23,20 +23,43 @@ pkg_check_modules(GUMBO REQUIRED gumbo)
|
|||
# 包含目录
|
||||
include_directories(
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/src/core
|
||||
${CMAKE_SOURCE_DIR}/src/render
|
||||
${CMAKE_SOURCE_DIR}/src/layout
|
||||
${CMAKE_SOURCE_DIR}/src/parser
|
||||
${CMAKE_SOURCE_DIR}/src/network
|
||||
${CMAKE_SOURCE_DIR}/src/input
|
||||
${CMAKE_SOURCE_DIR}/src/utils
|
||||
${CURL_INCLUDE_DIRS}
|
||||
${CURSES_INCLUDE_DIRS}
|
||||
${GUMBO_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
# ==================== Terminal 测试程序 ====================
|
||||
# ==================== TUT 主程序 ====================
|
||||
|
||||
add_executable(tut
|
||||
src/main.cpp
|
||||
src/browser.cpp
|
||||
src/http_client.cpp
|
||||
src/input_handler.cpp
|
||||
src/bookmark.cpp
|
||||
src/render/terminal.cpp
|
||||
src/render/renderer.cpp
|
||||
src/render/layout.cpp
|
||||
src/render/image.cpp
|
||||
src/utils/unicode.cpp
|
||||
src/dom_tree.cpp
|
||||
src/html_parser.cpp
|
||||
)
|
||||
|
||||
target_link_directories(tut PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(tut
|
||||
${CURSES_LIBRARIES}
|
||||
CURL::libcurl
|
||||
${GUMBO_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== 测试程序 ====================
|
||||
|
||||
# Terminal 测试
|
||||
add_executable(test_terminal
|
||||
src/render/terminal.cpp
|
||||
tests/test_terminal.cpp
|
||||
|
|
@ -46,8 +69,7 @@ target_link_libraries(test_terminal
|
|||
${CURSES_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== Renderer 测试程序 ====================
|
||||
|
||||
# Renderer 测试
|
||||
add_executable(test_renderer
|
||||
src/render/terminal.cpp
|
||||
src/render/renderer.cpp
|
||||
|
|
@ -59,8 +81,7 @@ target_link_libraries(test_renderer
|
|||
${CURSES_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== Layout 测试程序 ====================
|
||||
|
||||
# Layout 测试
|
||||
add_executable(test_layout
|
||||
src/render/terminal.cpp
|
||||
src/render/renderer.cpp
|
||||
|
|
@ -81,57 +102,7 @@ target_link_libraries(test_layout
|
|||
${GUMBO_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== TUT 2.0 主程序 ====================
|
||||
|
||||
add_executable(tut2
|
||||
src/main_v2.cpp
|
||||
src/browser_v2.cpp
|
||||
src/http_client.cpp
|
||||
src/input_handler.cpp
|
||||
src/bookmark.cpp
|
||||
src/render/terminal.cpp
|
||||
src/render/renderer.cpp
|
||||
src/render/layout.cpp
|
||||
src/render/image.cpp
|
||||
src/utils/unicode.cpp
|
||||
src/dom_tree.cpp
|
||||
src/html_parser.cpp
|
||||
)
|
||||
|
||||
target_link_directories(tut2 PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(tut2
|
||||
${CURSES_LIBRARIES}
|
||||
CURL::libcurl
|
||||
${GUMBO_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== 旧版主程序 (向后兼容) ====================
|
||||
|
||||
add_executable(tut
|
||||
src/main.cpp
|
||||
src/browser.cpp
|
||||
src/http_client.cpp
|
||||
src/text_renderer.cpp
|
||||
src/input_handler.cpp
|
||||
src/dom_tree.cpp
|
||||
src/html_parser.cpp
|
||||
)
|
||||
|
||||
target_link_directories(tut PRIVATE
|
||||
${GUMBO_LIBRARY_DIRS}
|
||||
)
|
||||
|
||||
target_link_libraries(tut
|
||||
${CURSES_LIBRARIES}
|
||||
CURL::libcurl
|
||||
${GUMBO_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== HTTP 异步测试程序 ====================
|
||||
|
||||
# HTTP 异步测试
|
||||
add_executable(test_http_async
|
||||
src/http_client.cpp
|
||||
tests/test_http_async.cpp
|
||||
|
|
@ -141,8 +112,7 @@ target_link_libraries(test_http_async
|
|||
CURL::libcurl
|
||||
)
|
||||
|
||||
# ==================== HTML 解析测试程序 ====================
|
||||
|
||||
# HTML 解析测试
|
||||
add_executable(test_html_parse
|
||||
src/html_parser.cpp
|
||||
src/dom_tree.cpp
|
||||
|
|
@ -157,8 +127,7 @@ target_link_libraries(test_html_parse
|
|||
${GUMBO_LIBRARIES}
|
||||
)
|
||||
|
||||
# ==================== 书签测试程序 ====================
|
||||
|
||||
# 书签测试
|
||||
add_executable(test_bookmark
|
||||
src/bookmark.cpp
|
||||
tests/test_bookmark.cpp
|
||||
|
|
|
|||
1264
src/browser.cpp
1264
src/browser.cpp
File diff suppressed because it is too large
Load diff
|
|
@ -2,12 +2,20 @@
|
|||
|
||||
#include "http_client.h"
|
||||
#include "html_parser.h"
|
||||
#include "text_renderer.h"
|
||||
#include "input_handler.h"
|
||||
#include "render/terminal.h"
|
||||
#include "render/renderer.h"
|
||||
#include "render/layout.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* Browser - TUT 终端浏览器
|
||||
*
|
||||
* 使用 Terminal + FrameBuffer + Renderer + LayoutEngine 架构
|
||||
* 支持 True Color, Unicode, 差分渲染
|
||||
*/
|
||||
class Browser {
|
||||
public:
|
||||
Browser();
|
||||
|
|
|
|||
|
|
@ -1,957 +0,0 @@
|
|||
#include "browser_v2.h"
|
||||
#include "dom_tree.h"
|
||||
#include "bookmark.h"
|
||||
#include "render/colors.h"
|
||||
#include "render/decorations.h"
|
||||
#include "render/image.h"
|
||||
#include "utils/unicode.h"
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <map>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <chrono>
|
||||
#include <ncurses.h>
|
||||
|
||||
using namespace tut;
|
||||
|
||||
// 浏览器加载状态
|
||||
enum class LoadingState {
|
||||
IDLE, // 空闲
|
||||
LOADING_PAGE, // 正在加载页面
|
||||
LOADING_IMAGES // 正在加载图片
|
||||
};
|
||||
|
||||
// 加载动画帧
|
||||
static const char* SPINNER_FRAMES[] = {
|
||||
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"
|
||||
};
|
||||
static const int SPINNER_FRAME_COUNT = 10;
|
||||
|
||||
// 缓存条目
|
||||
struct CacheEntry {
|
||||
DocumentTree tree;
|
||||
std::string html;
|
||||
std::chrono::steady_clock::time_point timestamp;
|
||||
|
||||
bool is_expired(int max_age_seconds = 300) const {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto age = std::chrono::duration_cast<std::chrono::seconds>(now - timestamp).count();
|
||||
return age > max_age_seconds;
|
||||
}
|
||||
};
|
||||
|
||||
class BrowserV2::Impl {
|
||||
public:
|
||||
// 网络和解析
|
||||
HttpClient http_client;
|
||||
HtmlParser html_parser;
|
||||
InputHandler input_handler;
|
||||
tut::BookmarkManager bookmark_manager;
|
||||
|
||||
// 新渲染系统
|
||||
Terminal terminal;
|
||||
std::unique_ptr<FrameBuffer> framebuffer;
|
||||
std::unique_ptr<Renderer> renderer;
|
||||
std::unique_ptr<LayoutEngine> layout_engine;
|
||||
|
||||
// 文档状态
|
||||
DocumentTree current_tree;
|
||||
LayoutResult current_layout;
|
||||
std::string current_url;
|
||||
std::vector<std::string> history;
|
||||
int history_pos = -1;
|
||||
|
||||
// 视图状态
|
||||
int scroll_pos = 0;
|
||||
int active_link = -1;
|
||||
int active_field = -1;
|
||||
std::string status_message;
|
||||
std::string search_term;
|
||||
|
||||
int screen_width = 0;
|
||||
int screen_height = 0;
|
||||
|
||||
// Marks support
|
||||
std::map<char, int> marks;
|
||||
|
||||
// 搜索相关
|
||||
SearchContext search_ctx;
|
||||
|
||||
// 页面缓存
|
||||
std::map<std::string, CacheEntry> page_cache;
|
||||
static constexpr int CACHE_MAX_AGE = 300; // 5分钟缓存
|
||||
static constexpr size_t CACHE_MAX_SIZE = 20; // 最多缓存20个页面
|
||||
|
||||
// 异步加载状态
|
||||
LoadingState loading_state = LoadingState::IDLE;
|
||||
std::string pending_url; // 正在加载的URL
|
||||
bool pending_force_refresh = false;
|
||||
int spinner_frame = 0;
|
||||
std::chrono::steady_clock::time_point last_spinner_update;
|
||||
|
||||
bool init_screen() {
|
||||
if (!terminal.init()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
terminal.get_size(screen_width, screen_height);
|
||||
terminal.use_alternate_screen(true);
|
||||
terminal.hide_cursor();
|
||||
|
||||
// 创建渲染组件
|
||||
framebuffer = std::make_unique<FrameBuffer>(screen_width, screen_height);
|
||||
renderer = std::make_unique<Renderer>(terminal);
|
||||
layout_engine = std::make_unique<LayoutEngine>(screen_width);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void cleanup_screen() {
|
||||
terminal.show_cursor();
|
||||
terminal.use_alternate_screen(false);
|
||||
terminal.cleanup();
|
||||
}
|
||||
|
||||
void handle_resize() {
|
||||
terminal.get_size(screen_width, screen_height);
|
||||
framebuffer = std::make_unique<FrameBuffer>(screen_width, screen_height);
|
||||
layout_engine->set_viewport_width(screen_width);
|
||||
|
||||
// 重新布局当前文档
|
||||
if (current_tree.root) {
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
}
|
||||
|
||||
renderer->force_redraw();
|
||||
}
|
||||
|
||||
bool load_page(const std::string& url, bool force_refresh = false) {
|
||||
// 检查缓存
|
||||
auto cache_it = page_cache.find(url);
|
||||
bool use_cache = !force_refresh && cache_it != page_cache.end() &&
|
||||
!cache_it->second.is_expired(CACHE_MAX_AGE);
|
||||
|
||||
if (use_cache) {
|
||||
status_message = "⚡ Loading from cache...";
|
||||
draw_screen();
|
||||
|
||||
// 使用缓存的文档树
|
||||
// 注意:需要重新解析因为DocumentTree包含unique_ptr
|
||||
current_tree = html_parser.parse_tree(cache_it->second.html, url);
|
||||
status_message = "⚡ " + (current_tree.title.empty() ? url : current_tree.title);
|
||||
} else {
|
||||
status_message = "⏳ Connecting to " + extract_host(url) + "...";
|
||||
draw_screen();
|
||||
|
||||
auto response = http_client.fetch(url);
|
||||
|
||||
if (!response.is_success()) {
|
||||
status_message = "❌ " + (response.error_message.empty() ?
|
||||
"HTTP " + std::to_string(response.status_code) :
|
||||
response.error_message);
|
||||
return false;
|
||||
}
|
||||
|
||||
status_message = "📄 Parsing HTML...";
|
||||
draw_screen();
|
||||
|
||||
// 解析HTML
|
||||
current_tree = html_parser.parse_tree(response.body, url);
|
||||
|
||||
// 添加到缓存
|
||||
add_to_cache(url, response.body);
|
||||
|
||||
status_message = current_tree.title.empty() ? url : current_tree.title;
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
load_images(current_tree);
|
||||
|
||||
// 布局计算
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
|
||||
current_url = url;
|
||||
scroll_pos = 0;
|
||||
active_link = current_tree.links.empty() ? -1 : 0;
|
||||
active_field = current_tree.form_fields.empty() ? -1 : 0;
|
||||
search_ctx = SearchContext(); // 清除搜索状态
|
||||
search_term.clear();
|
||||
|
||||
// 更新历史(仅在非刷新时)
|
||||
if (!force_refresh) {
|
||||
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
|
||||
history.erase(history.begin() + history_pos + 1, history.end());
|
||||
}
|
||||
history.push_back(url);
|
||||
history_pos = history.size() - 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 启动异步页面加载
|
||||
void start_async_load(const std::string& url, bool force_refresh = false) {
|
||||
// 检查缓存
|
||||
auto cache_it = page_cache.find(url);
|
||||
bool use_cache = !force_refresh && cache_it != page_cache.end() &&
|
||||
!cache_it->second.is_expired(CACHE_MAX_AGE);
|
||||
|
||||
if (use_cache) {
|
||||
// 使用缓存,不需要网络请求
|
||||
status_message = "⚡ Loading from cache...";
|
||||
current_tree = html_parser.parse_tree(cache_it->second.html, url);
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
current_url = url;
|
||||
scroll_pos = 0;
|
||||
active_link = current_tree.links.empty() ? -1 : 0;
|
||||
active_field = current_tree.form_fields.empty() ? -1 : 0;
|
||||
search_ctx = SearchContext();
|
||||
search_term.clear();
|
||||
status_message = "⚡ " + (current_tree.title.empty() ? url : current_tree.title);
|
||||
|
||||
// 更新历史
|
||||
if (!force_refresh) {
|
||||
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
|
||||
history.erase(history.begin() + history_pos + 1, history.end());
|
||||
}
|
||||
history.push_back(url);
|
||||
history_pos = history.size() - 1;
|
||||
}
|
||||
|
||||
// 加载图片(仍然同步,可以后续优化)
|
||||
load_images(current_tree);
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
return;
|
||||
}
|
||||
|
||||
// 需要网络请求,启动异步加载
|
||||
pending_url = url;
|
||||
pending_force_refresh = force_refresh;
|
||||
loading_state = LoadingState::LOADING_PAGE;
|
||||
spinner_frame = 0;
|
||||
last_spinner_update = std::chrono::steady_clock::now();
|
||||
|
||||
status_message = std::string(SPINNER_FRAMES[0]) + " Connecting to " + extract_host(url) + "...";
|
||||
http_client.start_async_fetch(url);
|
||||
}
|
||||
|
||||
// 轮询异步加载状态,返回true表示还在加载中
|
||||
bool poll_loading() {
|
||||
if (loading_state == LoadingState::IDLE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新spinner动画
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - last_spinner_update).count();
|
||||
if (elapsed >= 80) { // 每80ms更新一帧
|
||||
spinner_frame = (spinner_frame + 1) % SPINNER_FRAME_COUNT;
|
||||
last_spinner_update = now;
|
||||
update_loading_status();
|
||||
}
|
||||
|
||||
if (loading_state == LoadingState::LOADING_PAGE) {
|
||||
auto state = http_client.poll_async();
|
||||
|
||||
switch (state) {
|
||||
case AsyncState::COMPLETE:
|
||||
handle_load_complete();
|
||||
return false;
|
||||
|
||||
case AsyncState::FAILED: {
|
||||
auto result = http_client.get_async_result();
|
||||
status_message = "❌ " + (result.error_message.empty() ?
|
||||
"Connection failed" : result.error_message);
|
||||
loading_state = LoadingState::IDLE;
|
||||
return false;
|
||||
}
|
||||
|
||||
case AsyncState::CANCELLED:
|
||||
status_message = "⚠ Loading cancelled";
|
||||
loading_state = LoadingState::IDLE;
|
||||
return false;
|
||||
|
||||
case AsyncState::LOADING:
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return loading_state != LoadingState::IDLE;
|
||||
}
|
||||
|
||||
// 更新加载状态消息
|
||||
void update_loading_status() {
|
||||
std::string spinner = SPINNER_FRAMES[spinner_frame];
|
||||
if (loading_state == LoadingState::LOADING_PAGE) {
|
||||
status_message = spinner + " Loading " + extract_host(pending_url) + "...";
|
||||
} else if (loading_state == LoadingState::LOADING_IMAGES) {
|
||||
status_message = spinner + " Loading images...";
|
||||
}
|
||||
}
|
||||
|
||||
// 处理页面加载完成
|
||||
void handle_load_complete() {
|
||||
auto response = http_client.get_async_result();
|
||||
|
||||
if (!response.is_success()) {
|
||||
status_message = "❌ HTTP " + std::to_string(response.status_code);
|
||||
loading_state = LoadingState::IDLE;
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析HTML
|
||||
current_tree = html_parser.parse_tree(response.body, pending_url);
|
||||
|
||||
// 添加到缓存
|
||||
add_to_cache(pending_url, response.body);
|
||||
|
||||
// 布局计算
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
|
||||
current_url = pending_url;
|
||||
scroll_pos = 0;
|
||||
active_link = current_tree.links.empty() ? -1 : 0;
|
||||
active_field = current_tree.form_fields.empty() ? -1 : 0;
|
||||
search_ctx = SearchContext();
|
||||
search_term.clear();
|
||||
|
||||
// 更新历史(仅在非刷新时)
|
||||
if (!pending_force_refresh) {
|
||||
if (history_pos >= 0 && history_pos < static_cast<int>(history.size()) - 1) {
|
||||
history.erase(history.begin() + history_pos + 1, history.end());
|
||||
}
|
||||
history.push_back(pending_url);
|
||||
history_pos = history.size() - 1;
|
||||
}
|
||||
|
||||
status_message = current_tree.title.empty() ? pending_url : current_tree.title;
|
||||
|
||||
// 加载图片(目前仍同步,可后续优化为异步)
|
||||
load_images(current_tree);
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
|
||||
loading_state = LoadingState::IDLE;
|
||||
}
|
||||
|
||||
// 取消加载
|
||||
void cancel_loading() {
|
||||
if (loading_state != LoadingState::IDLE) {
|
||||
http_client.cancel_async();
|
||||
loading_state = LoadingState::IDLE;
|
||||
status_message = "⚠ Cancelled";
|
||||
}
|
||||
}
|
||||
|
||||
void add_to_cache(const std::string& url, const std::string& html) {
|
||||
// 限制缓存大小
|
||||
if (page_cache.size() >= CACHE_MAX_SIZE) {
|
||||
// 移除最老的缓存条目
|
||||
auto oldest = page_cache.begin();
|
||||
for (auto it = page_cache.begin(); it != page_cache.end(); ++it) {
|
||||
if (it->second.timestamp < oldest->second.timestamp) {
|
||||
oldest = it;
|
||||
}
|
||||
}
|
||||
page_cache.erase(oldest);
|
||||
}
|
||||
|
||||
CacheEntry entry;
|
||||
entry.html = html;
|
||||
entry.timestamp = std::chrono::steady_clock::now();
|
||||
page_cache[url] = std::move(entry);
|
||||
}
|
||||
|
||||
// 下载并解码页面中的图片
|
||||
void load_images(DocumentTree& tree) {
|
||||
if (tree.images.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
int total = static_cast<int>(tree.images.size());
|
||||
|
||||
for (DomNode* img_node : tree.images) {
|
||||
if (img_node->img_src.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
loaded++;
|
||||
status_message = "🖼 Loading image " + std::to_string(loaded) + "/" + std::to_string(total) + "...";
|
||||
draw_screen();
|
||||
|
||||
// 下载图片
|
||||
auto response = http_client.fetch_binary(img_node->img_src);
|
||||
if (!response.is_success() || response.data.empty()) {
|
||||
continue; // 跳过失败的图片
|
||||
}
|
||||
|
||||
// 解码图片
|
||||
tut::ImageData img_data = tut::ImageRenderer::load_from_memory(response.data);
|
||||
if (img_data.is_valid()) {
|
||||
img_node->image_data = std::move(img_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 从URL中提取主机名
|
||||
std::string extract_host(const std::string& url) {
|
||||
// 简单提取:找到://之后的部分,到第一个/为止
|
||||
size_t proto_end = url.find("://");
|
||||
if (proto_end == std::string::npos) {
|
||||
return url;
|
||||
}
|
||||
size_t host_start = proto_end + 3;
|
||||
size_t host_end = url.find('/', host_start);
|
||||
if (host_end == std::string::npos) {
|
||||
return url.substr(host_start);
|
||||
}
|
||||
return url.substr(host_start, host_end - host_start);
|
||||
}
|
||||
|
||||
void draw_screen() {
|
||||
// 清空缓冲区
|
||||
framebuffer->clear_with_color(colors::BG_PRIMARY);
|
||||
|
||||
int content_height = screen_height - 1; // 留出状态栏
|
||||
|
||||
// 渲染文档内容
|
||||
RenderContext render_ctx;
|
||||
render_ctx.active_link = active_link;
|
||||
render_ctx.active_field = active_field;
|
||||
render_ctx.search = search_ctx.enabled ? &search_ctx : nullptr;
|
||||
|
||||
DocumentRenderer doc_renderer(*framebuffer);
|
||||
doc_renderer.render(current_layout, scroll_pos, render_ctx);
|
||||
|
||||
// 渲染状态栏
|
||||
draw_status_bar(content_height);
|
||||
|
||||
// 渲染到终端
|
||||
renderer->render(*framebuffer);
|
||||
}
|
||||
|
||||
void draw_status_bar(int y) {
|
||||
// 状态栏背景
|
||||
for (int x = 0; x < screen_width; ++x) {
|
||||
framebuffer->set_cell(x, y, Cell{" ", colors::STATUSBAR_FG, colors::STATUSBAR_BG, ATTR_NONE});
|
||||
}
|
||||
|
||||
// 左侧: 模式
|
||||
std::string mode_str;
|
||||
InputMode mode = input_handler.get_mode();
|
||||
switch (mode) {
|
||||
case InputMode::NORMAL: mode_str = "NORMAL"; break;
|
||||
case InputMode::COMMAND:
|
||||
case InputMode::SEARCH: mode_str = input_handler.get_buffer(); break;
|
||||
default: mode_str = ""; break;
|
||||
}
|
||||
framebuffer->set_text(1, y, mode_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
||||
|
||||
// 中间: 状态消息或链接URL
|
||||
std::string display_msg;
|
||||
if (mode == InputMode::NORMAL) {
|
||||
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
|
||||
display_msg = current_tree.links[active_link].url;
|
||||
}
|
||||
if (display_msg.empty()) {
|
||||
display_msg = status_message;
|
||||
}
|
||||
|
||||
if (!display_msg.empty()) {
|
||||
// 截断过长的消息
|
||||
size_t max_len = screen_width - mode_str.length() - 20;
|
||||
if (display_msg.length() > max_len) {
|
||||
display_msg = display_msg.substr(0, max_len - 3) + "...";
|
||||
}
|
||||
int msg_x = static_cast<int>(mode_str.length()) + 3;
|
||||
framebuffer->set_text(msg_x, y, display_msg, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧: 位置信息
|
||||
int total_lines = current_layout.total_lines;
|
||||
int visible_lines = screen_height - 1;
|
||||
int percentage = (total_lines > 0 && scroll_pos + visible_lines < total_lines) ?
|
||||
(scroll_pos * 100) / total_lines : 100;
|
||||
if (total_lines == 0) percentage = 0;
|
||||
|
||||
std::string pos_str = std::to_string(scroll_pos + 1) + "/" +
|
||||
std::to_string(total_lines) + " " +
|
||||
std::to_string(percentage) + "%";
|
||||
int pos_x = screen_width - static_cast<int>(pos_str.length()) - 1;
|
||||
framebuffer->set_text(pos_x, y, pos_str, colors::STATUSBAR_FG, colors::STATUSBAR_BG);
|
||||
}
|
||||
|
||||
void handle_action(const InputResult& result) {
|
||||
int visible_lines = screen_height - 1;
|
||||
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
|
||||
int count = result.has_count ? result.count : 1;
|
||||
|
||||
switch (result.action) {
|
||||
case Action::SCROLL_UP:
|
||||
scroll_pos = std::max(0, scroll_pos - count);
|
||||
break;
|
||||
case Action::SCROLL_DOWN:
|
||||
scroll_pos = std::min(max_scroll, scroll_pos + count);
|
||||
break;
|
||||
case Action::SCROLL_PAGE_UP:
|
||||
scroll_pos = std::max(0, scroll_pos - visible_lines);
|
||||
break;
|
||||
case Action::SCROLL_PAGE_DOWN:
|
||||
scroll_pos = std::min(max_scroll, scroll_pos + visible_lines);
|
||||
break;
|
||||
case Action::GOTO_TOP:
|
||||
scroll_pos = 0;
|
||||
break;
|
||||
case Action::GOTO_BOTTOM:
|
||||
scroll_pos = max_scroll;
|
||||
break;
|
||||
case Action::GOTO_LINE:
|
||||
if (result.number > 0) {
|
||||
scroll_pos = std::min(result.number - 1, max_scroll);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::NEXT_LINK:
|
||||
if (!current_tree.links.empty()) {
|
||||
active_link = (active_link + 1) % current_tree.links.size();
|
||||
scroll_to_link(active_link);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::PREV_LINK:
|
||||
if (!current_tree.links.empty()) {
|
||||
active_link = (active_link - 1 + current_tree.links.size()) % current_tree.links.size();
|
||||
scroll_to_link(active_link);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::FOLLOW_LINK:
|
||||
if (active_link >= 0 && active_link < static_cast<int>(current_tree.links.size())) {
|
||||
start_async_load(current_tree.links[active_link].url);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::GO_BACK:
|
||||
if (history_pos > 0) {
|
||||
history_pos--;
|
||||
start_async_load(history[history_pos]);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::GO_FORWARD:
|
||||
if (history_pos < static_cast<int>(history.size()) - 1) {
|
||||
history_pos++;
|
||||
start_async_load(history[history_pos]);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::OPEN_URL:
|
||||
if (!result.text.empty()) {
|
||||
start_async_load(result.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::REFRESH:
|
||||
if (!current_url.empty()) {
|
||||
start_async_load(current_url, true); // 强制刷新,跳过缓存
|
||||
}
|
||||
break;
|
||||
|
||||
case Action::SEARCH_FORWARD: {
|
||||
int count = perform_search(result.text);
|
||||
if (count > 0) {
|
||||
status_message = "Match 1/" + std::to_string(count);
|
||||
} else if (!result.text.empty()) {
|
||||
status_message = "Pattern not found: " + result.text;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case Action::SEARCH_NEXT:
|
||||
search_next();
|
||||
break;
|
||||
|
||||
case Action::SEARCH_PREV:
|
||||
search_prev();
|
||||
break;
|
||||
|
||||
case Action::HELP:
|
||||
show_help();
|
||||
break;
|
||||
|
||||
case Action::ADD_BOOKMARK:
|
||||
add_bookmark();
|
||||
break;
|
||||
|
||||
case Action::REMOVE_BOOKMARK:
|
||||
remove_bookmark();
|
||||
break;
|
||||
|
||||
case Action::SHOW_BOOKMARKS:
|
||||
show_bookmarks();
|
||||
break;
|
||||
|
||||
case Action::QUIT:
|
||||
break; // 在main loop处理
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行搜索,返回匹配数量
|
||||
int perform_search(const std::string& term) {
|
||||
search_ctx.matches.clear();
|
||||
search_ctx.current_match_idx = -1;
|
||||
search_ctx.enabled = false;
|
||||
|
||||
if (term.empty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
search_term = term;
|
||||
search_ctx.enabled = true;
|
||||
|
||||
// 遍历所有布局块和行,查找匹配
|
||||
int doc_line = 0;
|
||||
for (const auto& block : current_layout.blocks) {
|
||||
// 上边距
|
||||
doc_line += block.margin_top;
|
||||
|
||||
// 内容行
|
||||
for (const auto& line : block.lines) {
|
||||
// 构建整行文本用于搜索
|
||||
std::string line_text;
|
||||
|
||||
for (const auto& span : line.spans) {
|
||||
line_text += span.text;
|
||||
}
|
||||
|
||||
// 搜索匹配(大小写不敏感)
|
||||
std::string lower_line = line_text;
|
||||
std::string lower_term = term;
|
||||
std::transform(lower_line.begin(), lower_line.end(), lower_line.begin(), ::tolower);
|
||||
std::transform(lower_term.begin(), lower_term.end(), lower_term.begin(), ::tolower);
|
||||
|
||||
size_t pos = 0;
|
||||
while ((pos = lower_line.find(lower_term, pos)) != std::string::npos) {
|
||||
SearchMatch match;
|
||||
match.line = doc_line;
|
||||
match.start_col = line.indent + static_cast<int>(pos);
|
||||
match.length = static_cast<int>(term.length());
|
||||
search_ctx.matches.push_back(match);
|
||||
pos += 1; // 继续搜索下一个匹配
|
||||
}
|
||||
|
||||
doc_line++;
|
||||
}
|
||||
|
||||
// 下边距
|
||||
doc_line += block.margin_bottom;
|
||||
}
|
||||
|
||||
// 如果有匹配,跳转到第一个
|
||||
if (!search_ctx.matches.empty()) {
|
||||
search_ctx.current_match_idx = 0;
|
||||
scroll_to_match(0);
|
||||
}
|
||||
|
||||
return static_cast<int>(search_ctx.matches.size());
|
||||
}
|
||||
|
||||
// 跳转到指定匹配
|
||||
void scroll_to_match(int idx) {
|
||||
if (idx < 0 || idx >= static_cast<int>(search_ctx.matches.size())) {
|
||||
return;
|
||||
}
|
||||
|
||||
search_ctx.current_match_idx = idx;
|
||||
int match_line = search_ctx.matches[idx].line;
|
||||
int visible_lines = screen_height - 1;
|
||||
|
||||
// 确保匹配行在可见区域
|
||||
if (match_line < scroll_pos) {
|
||||
scroll_pos = match_line;
|
||||
} else if (match_line >= scroll_pos + visible_lines) {
|
||||
scroll_pos = match_line - visible_lines / 2;
|
||||
}
|
||||
|
||||
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
|
||||
scroll_pos = std::max(0, std::min(scroll_pos, max_scroll));
|
||||
}
|
||||
|
||||
// 搜索下一个
|
||||
void search_next() {
|
||||
if (search_ctx.matches.empty()) {
|
||||
if (!search_term.empty()) {
|
||||
status_message = "Pattern not found: " + search_term;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
search_ctx.current_match_idx = (search_ctx.current_match_idx + 1) % search_ctx.matches.size();
|
||||
scroll_to_match(search_ctx.current_match_idx);
|
||||
status_message = "Match " + std::to_string(search_ctx.current_match_idx + 1) +
|
||||
"/" + std::to_string(search_ctx.matches.size());
|
||||
}
|
||||
|
||||
// 搜索上一个
|
||||
void search_prev() {
|
||||
if (search_ctx.matches.empty()) {
|
||||
if (!search_term.empty()) {
|
||||
status_message = "Pattern not found: " + search_term;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
search_ctx.current_match_idx = (search_ctx.current_match_idx - 1 + search_ctx.matches.size()) % search_ctx.matches.size();
|
||||
scroll_to_match(search_ctx.current_match_idx);
|
||||
status_message = "Match " + std::to_string(search_ctx.current_match_idx + 1) +
|
||||
"/" + std::to_string(search_ctx.matches.size());
|
||||
}
|
||||
|
||||
// 滚动到链接位置
|
||||
void scroll_to_link(int link_idx) {
|
||||
if (link_idx < 0 || link_idx >= static_cast<int>(current_layout.link_positions.size())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& pos = current_layout.link_positions[link_idx];
|
||||
if (pos.start_line < 0) {
|
||||
return; // 链接位置无效
|
||||
}
|
||||
|
||||
int visible_lines = screen_height - 1;
|
||||
int link_line = pos.start_line;
|
||||
|
||||
// 确保链接行在可见区域
|
||||
if (link_line < scroll_pos) {
|
||||
// 链接在视口上方,滚动使其出现在顶部附近
|
||||
scroll_pos = std::max(0, link_line - 2);
|
||||
} else if (link_line >= scroll_pos + visible_lines) {
|
||||
// 链接在视口下方,滚动使其出现在中间
|
||||
scroll_pos = link_line - visible_lines / 2;
|
||||
}
|
||||
|
||||
int max_scroll = std::max(0, current_layout.total_lines - visible_lines);
|
||||
scroll_pos = std::max(0, std::min(scroll_pos, max_scroll));
|
||||
}
|
||||
|
||||
void show_help() {
|
||||
std::string help_html = R"(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>TUT 2.0 Help</title></head>
|
||||
<body>
|
||||
<h1>TUT 2.0 - Terminal Browser</h1>
|
||||
|
||||
<h2>Navigation</h2>
|
||||
<ul>
|
||||
<li>j/k - Scroll down/up</li>
|
||||
<li>Ctrl+d/Ctrl+u - Page down/up</li>
|
||||
<li>gg - Go to top</li>
|
||||
<li>G - Go to bottom</li>
|
||||
</ul>
|
||||
|
||||
<h2>Links</h2>
|
||||
<ul>
|
||||
<li>Tab - Next link</li>
|
||||
<li>Shift+Tab - Previous link</li>
|
||||
<li>Enter - Follow link</li>
|
||||
</ul>
|
||||
|
||||
<h2>History</h2>
|
||||
<ul>
|
||||
<li>h - Go back</li>
|
||||
<li>l - Go forward</li>
|
||||
</ul>
|
||||
|
||||
<h2>Search</h2>
|
||||
<ul>
|
||||
<li>/ - Search forward</li>
|
||||
<li>n - Next match</li>
|
||||
<li>N - Previous match</li>
|
||||
</ul>
|
||||
|
||||
<h2>Bookmarks</h2>
|
||||
<ul>
|
||||
<li>B - Add bookmark</li>
|
||||
<li>D - Remove bookmark</li>
|
||||
<li>:bookmarks - Show bookmarks</li>
|
||||
</ul>
|
||||
|
||||
<h2>Commands</h2>
|
||||
<ul>
|
||||
<li>:o URL - Open URL</li>
|
||||
<li>:bookmarks - Show bookmarks</li>
|
||||
<li>:q - Quit</li>
|
||||
<li>? - Show this help</li>
|
||||
</ul>
|
||||
|
||||
<h2>Forms</h2>
|
||||
<ul>
|
||||
<li>Tab - Navigate links and form fields</li>
|
||||
<li>Enter - Activate link or submit form</li>
|
||||
</ul>
|
||||
|
||||
<hr>
|
||||
<p>TUT 2.0 - A modern terminal browser with True Color support</p>
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
current_tree = html_parser.parse_tree(help_html, "help://");
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
scroll_pos = 0;
|
||||
active_link = current_tree.links.empty() ? -1 : 0;
|
||||
status_message = "Help - Press any key to continue";
|
||||
}
|
||||
|
||||
void show_bookmarks() {
|
||||
std::ostringstream html;
|
||||
html << R"(
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Bookmarks</title></head>
|
||||
<body>
|
||||
<h1>Bookmarks</h1>
|
||||
)";
|
||||
|
||||
const auto& bookmarks = bookmark_manager.get_all();
|
||||
|
||||
if (bookmarks.empty()) {
|
||||
html << "<p>No bookmarks yet.</p>\n";
|
||||
html << "<p>Press <b>B</b> on any page to add a bookmark.</p>\n";
|
||||
} else {
|
||||
html << "<ul>\n";
|
||||
for (const auto& bm : bookmarks) {
|
||||
html << "<li><a href=\"" << bm.url << "\">"
|
||||
<< (bm.title.empty() ? bm.url : bm.title)
|
||||
<< "</a></li>\n";
|
||||
}
|
||||
html << "</ul>\n";
|
||||
html << "<hr>\n";
|
||||
html << "<p>" << bookmarks.size() << " bookmark(s). Press D on any page to remove its bookmark.</p>\n";
|
||||
}
|
||||
|
||||
html << R"(
|
||||
</body>
|
||||
</html>
|
||||
)";
|
||||
|
||||
current_tree = html_parser.parse_tree(html.str(), "bookmarks://");
|
||||
current_layout = layout_engine->layout(current_tree);
|
||||
scroll_pos = 0;
|
||||
active_link = current_tree.links.empty() ? -1 : 0;
|
||||
status_message = "Bookmarks";
|
||||
}
|
||||
|
||||
void add_bookmark() {
|
||||
if (current_url.empty() || current_url.find("://") == std::string::npos) {
|
||||
status_message = "Cannot bookmark this page";
|
||||
return;
|
||||
}
|
||||
|
||||
// 不要书签特殊页面
|
||||
if (current_url.find("help://") == 0 || current_url.find("bookmarks://") == 0) {
|
||||
status_message = "Cannot bookmark special pages";
|
||||
return;
|
||||
}
|
||||
|
||||
std::string title = current_tree.title.empty() ? current_url : current_tree.title;
|
||||
|
||||
if (bookmark_manager.add(current_url, title)) {
|
||||
status_message = "Bookmarked: " + title;
|
||||
} else {
|
||||
status_message = "Already bookmarked";
|
||||
}
|
||||
}
|
||||
|
||||
void remove_bookmark() {
|
||||
if (current_url.empty()) {
|
||||
status_message = "No page to unbookmark";
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookmark_manager.remove(current_url)) {
|
||||
status_message = "Bookmark removed";
|
||||
} else {
|
||||
status_message = "Not bookmarked";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
BrowserV2::BrowserV2() : pImpl(std::make_unique<Impl>()) {
|
||||
pImpl->input_handler.set_status_callback([this](const std::string& msg) {
|
||||
pImpl->status_message = msg;
|
||||
});
|
||||
}
|
||||
|
||||
BrowserV2::~BrowserV2() = default;
|
||||
|
||||
void BrowserV2::run(const std::string& initial_url) {
|
||||
if (!pImpl->init_screen()) {
|
||||
throw std::runtime_error("Failed to initialize terminal");
|
||||
}
|
||||
|
||||
if (!initial_url.empty()) {
|
||||
pImpl->start_async_load(initial_url);
|
||||
} else {
|
||||
pImpl->show_help();
|
||||
}
|
||||
|
||||
bool running = true;
|
||||
while (running) {
|
||||
// 轮询异步加载状态
|
||||
pImpl->poll_loading();
|
||||
|
||||
// 渲染屏幕
|
||||
pImpl->draw_screen();
|
||||
|
||||
// 获取输入(非阻塞,50ms超时)
|
||||
int ch = pImpl->terminal.get_key(50);
|
||||
if (ch == -1) continue;
|
||||
|
||||
// 处理窗口大小变化
|
||||
if (ch == KEY_RESIZE) {
|
||||
pImpl->handle_resize();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果正在加载,Esc可以取消
|
||||
if (pImpl->loading_state != LoadingState::IDLE && ch == 27) { // 27 = Esc
|
||||
pImpl->cancel_loading();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 加载时忽略大部分输入,只允许取消和退出
|
||||
if (pImpl->loading_state != LoadingState::IDLE) {
|
||||
if (ch == 'q' || ch == 'Q') {
|
||||
running = false;
|
||||
}
|
||||
continue; // 忽略其他输入
|
||||
}
|
||||
|
||||
auto result = pImpl->input_handler.handle_key(ch);
|
||||
if (result.action == Action::QUIT) {
|
||||
running = false;
|
||||
} else if (result.action != Action::NONE) {
|
||||
pImpl->handle_action(result);
|
||||
}
|
||||
}
|
||||
|
||||
pImpl->cleanup_screen();
|
||||
}
|
||||
|
||||
bool BrowserV2::load_url(const std::string& url) {
|
||||
return pImpl->load_page(url);
|
||||
}
|
||||
|
||||
std::string BrowserV2::get_current_url() const {
|
||||
return pImpl->current_url;
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "http_client.h"
|
||||
#include "html_parser.h"
|
||||
#include "input_handler.h"
|
||||
#include "render/terminal.h"
|
||||
#include "render/renderer.h"
|
||||
#include "render/layout.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
/**
|
||||
* BrowserV2 - 使用新渲染系统的浏览器
|
||||
*
|
||||
* 使用 Terminal + FrameBuffer + Renderer + LayoutEngine 架构
|
||||
* 支持 True Color, Unicode, 差分渲染
|
||||
*/
|
||||
class BrowserV2 {
|
||||
public:
|
||||
BrowserV2();
|
||||
~BrowserV2();
|
||||
|
||||
void run(const std::string& initial_url = "");
|
||||
bool load_url(const std::string& url);
|
||||
std::string get_current_url() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
|
@ -48,7 +48,12 @@ bool DomNode::is_block_element() const {
|
|||
tag_name == "pre" || tag_name == "hr" ||
|
||||
tag_name == "table" || tag_name == "tr" ||
|
||||
tag_name == "th" || tag_name == "td" ||
|
||||
tag_name == "form" || tag_name == "fieldset";
|
||||
tag_name == "tbody" || tag_name == "thead" ||
|
||||
tag_name == "tfoot" || tag_name == "caption" ||
|
||||
tag_name == "form" || tag_name == "fieldset" ||
|
||||
tag_name == "figure" || tag_name == "figcaption" ||
|
||||
tag_name == "details" || tag_name == "summary" ||
|
||||
tag_name == "center" || tag_name == "address";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +96,11 @@ bool DomNode::should_render() const {
|
|||
std::string DomNode::get_all_text() const {
|
||||
std::string result;
|
||||
|
||||
// 过滤不应该提取文本的元素
|
||||
if (!should_render()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (node_type == NodeType::TEXT) {
|
||||
result = text_content;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
void print_usage(const char* prog_name) {
|
||||
std::cout << "TUT - Terminal User Interface Browser\n"
|
||||
<< "A vim-style terminal web browser for comfortable reading\n\n"
|
||||
<< "A vim-style terminal web browser with True Color support\n\n"
|
||||
<< "Usage: " << prog_name << " [URL]\n\n"
|
||||
<< "If no URL is provided, the browser will start with a help page.\n\n"
|
||||
<< "Examples:\n"
|
||||
|
|
@ -44,4 +44,3 @@ int main(int argc, char* argv[]) {
|
|||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
#include "browser_v2.h"
|
||||
#include <iostream>
|
||||
#include <cstring>
|
||||
|
||||
void print_usage(const char* prog_name) {
|
||||
std::cout << "TUT 2.0 - Terminal User Interface Browser\n"
|
||||
<< "A vim-style terminal web browser with True Color support\n\n"
|
||||
<< "Usage: " << prog_name << " [URL]\n\n"
|
||||
<< "If no URL is provided, the browser will start with a help page.\n\n"
|
||||
<< "Examples:\n"
|
||||
<< " " << prog_name << "\n"
|
||||
<< " " << prog_name << " https://example.com\n"
|
||||
<< " " << prog_name << " https://news.ycombinator.com\n\n"
|
||||
<< "Vim-style keybindings:\n"
|
||||
<< " j/k - Scroll down/up\n"
|
||||
<< " gg/G - Go to top/bottom\n"
|
||||
<< " / - Search\n"
|
||||
<< " Tab - Next link\n"
|
||||
<< " Enter - Follow link\n"
|
||||
<< " h/l - Back/Forward\n"
|
||||
<< " :o URL - Open URL\n"
|
||||
<< " :q - Quit\n"
|
||||
<< " ? - Show help\n\n"
|
||||
<< "New in 2.0:\n"
|
||||
<< " - True Color (24-bit) support\n"
|
||||
<< " - Improved Unicode handling\n"
|
||||
<< " - Differential rendering for better performance\n";
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
std::string initial_url;
|
||||
|
||||
if (argc > 1) {
|
||||
if (std::strcmp(argv[1], "-h") == 0 || std::strcmp(argv[1], "--help") == 0) {
|
||||
print_usage(argv[0]);
|
||||
return 0;
|
||||
}
|
||||
initial_url = argv[1];
|
||||
}
|
||||
|
||||
try {
|
||||
BrowserV2 browser;
|
||||
browser.run(initial_url);
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Error: " << e.what() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -77,20 +77,6 @@ void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<La
|
|||
return;
|
||||
}
|
||||
|
||||
// 处理容器元素(html, body, div, form等)- 递归处理子节点
|
||||
if (node->tag_name == "html" || node->tag_name == "body" ||
|
||||
node->tag_name == "head" || node->tag_name == "main" ||
|
||||
node->tag_name == "article" || node->tag_name == "section" ||
|
||||
node->tag_name == "div" || node->tag_name == "header" ||
|
||||
node->tag_name == "footer" || node->tag_name == "nav" ||
|
||||
node->tag_name == "aside" || node->tag_name == "form" ||
|
||||
node->tag_name == "fieldset") {
|
||||
for (const auto& child : node->children) {
|
||||
layout_node(child.get(), ctx, blocks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理表单内联元素
|
||||
if (node->element_type == ElementType::INPUT ||
|
||||
node->element_type == ElementType::BUTTON ||
|
||||
|
|
@ -106,10 +92,86 @@ void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<La
|
|||
return;
|
||||
}
|
||||
|
||||
// 处理块级元素
|
||||
if (node->is_block_element()) {
|
||||
layout_block_element(node, ctx, blocks);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理链接 - 当链接单独出现时(不在段落内),创建一个单独的块
|
||||
if (node->element_type == ElementType::LINK && node->link_index >= 0) {
|
||||
// 检查链接是否有可见文本
|
||||
std::string link_text = node->get_all_text();
|
||||
// 去除空白
|
||||
size_t start = link_text.find_first_not_of(" \t\n\r");
|
||||
size_t end = link_text.find_last_not_of(" \t\n\r");
|
||||
if (start != std::string::npos && end != std::string::npos) {
|
||||
link_text = link_text.substr(start, end - start + 1);
|
||||
} else {
|
||||
link_text = "";
|
||||
}
|
||||
|
||||
if (!link_text.empty()) {
|
||||
LayoutBlock block;
|
||||
block.type = ElementType::PARAGRAPH;
|
||||
block.margin_top = 0;
|
||||
block.margin_bottom = 0;
|
||||
|
||||
LayoutLine line;
|
||||
line.indent = MARGIN_LEFT;
|
||||
|
||||
StyledSpan span;
|
||||
span.text = link_text;
|
||||
span.fg = colors::LINK_FG;
|
||||
span.attrs = ATTR_UNDERLINE;
|
||||
span.link_index = node->link_index;
|
||||
line.spans.push_back(span);
|
||||
|
||||
block.lines.push_back(line);
|
||||
blocks.push_back(block);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理容器元素 - 递归处理子节点
|
||||
// 这包括:html, body, div, table, span, center 等所有容器类元素
|
||||
if (node->node_type == NodeType::ELEMENT && !node->children.empty()) {
|
||||
for (const auto& child : node->children) {
|
||||
layout_node(child.get(), ctx, blocks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理独立文本节点
|
||||
if (node->node_type == NodeType::TEXT && !node->text_content.empty()) {
|
||||
std::string text = node->text_content;
|
||||
// 去除首尾空白
|
||||
size_t start = text.find_first_not_of(" \t\n\r");
|
||||
size_t end = text.find_last_not_of(" \t\n\r");
|
||||
if (start != std::string::npos && end != std::string::npos) {
|
||||
text = text.substr(start, end - start + 1);
|
||||
} else {
|
||||
return; // 空白文本,跳过
|
||||
}
|
||||
|
||||
if (!text.empty()) {
|
||||
LayoutBlock block;
|
||||
block.type = ElementType::TEXT;
|
||||
block.margin_top = 0;
|
||||
block.margin_bottom = 0;
|
||||
|
||||
std::vector<StyledSpan> spans;
|
||||
StyledSpan span;
|
||||
span.text = text;
|
||||
span.fg = colors::FG_PRIMARY;
|
||||
spans.push_back(span);
|
||||
|
||||
block.lines = wrap_text(spans, content_width_, MARGIN_LEFT);
|
||||
if (!block.lines.empty()) {
|
||||
blocks.push_back(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 内联元素在块级元素内部处理
|
||||
}
|
||||
|
||||
void LayoutEngine::layout_block_element(const DomNode* node, Context& ctx, std::vector<LayoutBlock>& blocks) {
|
||||
|
|
@ -485,6 +547,41 @@ void LayoutEngine::layout_image_element(const DomNode* node, Context& /*ctx*/, s
|
|||
blocks.push_back(block);
|
||||
}
|
||||
|
||||
// 辅助函数:检查是否需要在两个文本之间添加空格
|
||||
static bool needs_space_between(const std::string& prev, const std::string& next) {
|
||||
if (prev.empty() || next.empty()) return false;
|
||||
|
||||
char last_char = prev.back();
|
||||
char first_char = next.front();
|
||||
|
||||
// 检查前一个是否以空白结尾
|
||||
bool prev_ends_with_space = (last_char == ' ' || last_char == '\t' ||
|
||||
last_char == '\n' || last_char == '\r');
|
||||
if (prev_ends_with_space) return false;
|
||||
|
||||
// 检查当前是否以空白开头
|
||||
bool curr_starts_with_space = (first_char == ' ' || first_char == '\t' ||
|
||||
first_char == '\n' || first_char == '\r');
|
||||
if (curr_starts_with_space) return false;
|
||||
|
||||
// 检查是否是标点符号(不需要空格)
|
||||
bool is_punct = (first_char == '.' || first_char == ',' ||
|
||||
first_char == '!' || first_char == '?' ||
|
||||
first_char == ':' || first_char == ';' ||
|
||||
first_char == ')' || first_char == ']' ||
|
||||
first_char == '}' || first_char == '|' ||
|
||||
first_char == '\'' || first_char == '"');
|
||||
if (is_punct) return false;
|
||||
|
||||
// 检查前一个字符是否是特殊符号(不需要空格)
|
||||
bool prev_is_open = (last_char == '(' || last_char == '[' ||
|
||||
last_char == '{' || last_char == '\'' ||
|
||||
last_char == '"');
|
||||
if (prev_is_open) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
|
||||
if (!node) return;
|
||||
|
||||
|
|
@ -502,10 +599,21 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
|
|||
int link_idx = node->link_index;
|
||||
|
||||
// 递归处理子节点
|
||||
for (const auto& child : node->children) {
|
||||
for (size_t i = 0; i < node->children.size(); ++i) {
|
||||
const auto& child = node->children[i];
|
||||
|
||||
if (child->node_type == NodeType::TEXT) {
|
||||
std::string text = child->text_content;
|
||||
|
||||
// 检查是否需要在之前的内容和当前内容之间添加空格
|
||||
if (!spans.empty() && !text.empty()) {
|
||||
if (needs_space_between(spans.back().text, text)) {
|
||||
spans.back().text += " ";
|
||||
}
|
||||
}
|
||||
|
||||
StyledSpan span;
|
||||
span.text = child->text_content;
|
||||
span.text = text;
|
||||
span.fg = fg;
|
||||
span.attrs = attrs;
|
||||
span.link_index = link_idx;
|
||||
|
|
@ -516,6 +624,16 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
|
|||
|
||||
spans.push_back(span);
|
||||
} else if (!child->is_block_element()) {
|
||||
// 获取子节点的全部文本,用于检查是否需要空格
|
||||
std::string child_text = child->get_all_text();
|
||||
|
||||
// 在递归调用前检查空格
|
||||
if (!spans.empty() && !child_text.empty()) {
|
||||
if (needs_space_between(spans.back().text, child_text)) {
|
||||
spans.back().text += " ";
|
||||
}
|
||||
}
|
||||
|
||||
collect_inline_content(child.get(), ctx, spans);
|
||||
}
|
||||
}
|
||||
|
|
@ -525,8 +643,17 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
|
|||
void LayoutEngine::layout_text(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
|
||||
if (!node || node->text_content.empty()) return;
|
||||
|
||||
std::string text = node->text_content;
|
||||
|
||||
// 检查是否需要在之前的内容和当前内容之间添加空格
|
||||
if (!spans.empty() && !text.empty()) {
|
||||
if (needs_space_between(spans.back().text, text)) {
|
||||
spans.back().text += " ";
|
||||
}
|
||||
}
|
||||
|
||||
StyledSpan span;
|
||||
span.text = node->text_content;
|
||||
span.text = text;
|
||||
span.fg = colors::FG_PRIMARY;
|
||||
|
||||
if (ctx.in_blockquote) {
|
||||
|
|
@ -546,12 +673,13 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
|
|||
LayoutLine current_line;
|
||||
current_line.indent = indent;
|
||||
size_t current_width = 0;
|
||||
bool is_line_start = true; // 整行的开始标记
|
||||
|
||||
for (const auto& span : spans) {
|
||||
// 分词处理
|
||||
std::istringstream iss(span.text);
|
||||
std::string word;
|
||||
bool first_word = true;
|
||||
bool first_word_in_span = true;
|
||||
|
||||
while (iss >> word) {
|
||||
size_t word_width = Unicode::display_width(word);
|
||||
|
|
@ -565,12 +693,20 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
|
|||
current_line = LayoutLine();
|
||||
current_line.indent = indent;
|
||||
current_width = 0;
|
||||
first_word = true;
|
||||
is_line_start = true;
|
||||
}
|
||||
|
||||
// 添加空格(如果不是行首)
|
||||
if (current_width > 0 && !first_word) {
|
||||
if (!current_line.spans.empty()) {
|
||||
// 添加空格(如果不是行首且不是第一个单词)
|
||||
// 需要在不同 span 之间也添加空格
|
||||
if (!is_line_start) {
|
||||
// 检查是否需要空格(避免在标点前加空格)
|
||||
char first_char = word.front();
|
||||
bool is_punct = (first_char == '.' || first_char == ',' ||
|
||||
first_char == '!' || first_char == '?' ||
|
||||
first_char == ':' || first_char == ';' ||
|
||||
first_char == ')' || first_char == ']' ||
|
||||
first_char == '}' || first_char == '|');
|
||||
if (!is_punct && !current_line.spans.empty()) {
|
||||
current_line.spans.back().text += " ";
|
||||
current_width += 1;
|
||||
}
|
||||
|
|
@ -581,7 +717,8 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
|
|||
word_span.text = word;
|
||||
current_line.spans.push_back(word_span);
|
||||
current_width += word_width;
|
||||
first_word = false;
|
||||
is_line_start = false;
|
||||
first_word_in_span = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,641 +0,0 @@
|
|||
#include "text_renderer.h"
|
||||
#include "dom_tree.h"
|
||||
#include <algorithm>
|
||||
#include <sstream>
|
||||
#include <cstring>
|
||||
#include <cwchar>
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
namespace {
|
||||
// Calculate display width of UTF-8 string (handling CJK characters)
|
||||
size_t display_width(const std::string& str) {
|
||||
size_t width = 0;
|
||||
for (size_t i = 0; i < str.length(); ) {
|
||||
unsigned char c = str[i];
|
||||
|
||||
if (c < 0x80) {
|
||||
// ASCII
|
||||
width += 1;
|
||||
i += 1;
|
||||
} else if ((c & 0xE0) == 0xC0) {
|
||||
// 2-byte UTF-8
|
||||
width += 1;
|
||||
i += 2;
|
||||
} else if ((c & 0xF0) == 0xE0) {
|
||||
// 3-byte UTF-8 (likely CJK)
|
||||
width += 2;
|
||||
i += 3;
|
||||
} else if ((c & 0xF8) == 0xF0) {
|
||||
// 4-byte UTF-8
|
||||
width += 2;
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
// Pad string to specific visual width
|
||||
std::string pad_string(const std::string& str, size_t target_width) {
|
||||
size_t current_width = display_width(str);
|
||||
if (current_width >= target_width) return str;
|
||||
return str + std::string(target_width - current_width, ' ');
|
||||
}
|
||||
|
||||
// Clean whitespace
|
||||
std::string clean_text(const std::string& text) {
|
||||
std::string result;
|
||||
bool in_space = false;
|
||||
|
||||
for (char c : text) {
|
||||
if (std::isspace(c)) {
|
||||
if (!in_space) {
|
||||
result += ' ';
|
||||
in_space = true;
|
||||
}
|
||||
} else {
|
||||
result += c;
|
||||
in_space = false;
|
||||
}
|
||||
}
|
||||
|
||||
size_t start = result.find_first_not_of(" \t\n\r");
|
||||
if (start == std::string::npos) return "";
|
||||
|
||||
size_t end = result.find_last_not_of(" \t\n\r");
|
||||
return result.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
struct LinkInfo {
|
||||
size_t start_pos;
|
||||
size_t end_pos;
|
||||
int link_index;
|
||||
int field_index;
|
||||
};
|
||||
|
||||
// Text wrapping with link preservation
|
||||
std::vector<std::pair<std::string, std::vector<LinkInfo>>> wrap_text_with_links(
|
||||
const std::string& text,
|
||||
int max_width,
|
||||
const std::vector<InlineLink>& links
|
||||
) {
|
||||
std::vector<std::pair<std::string, std::vector<LinkInfo>>> result;
|
||||
if (max_width <= 0) return result;
|
||||
|
||||
// 1. Insert [N] markers for links (form fields don't get [N])
|
||||
std::string marked_text;
|
||||
std::vector<LinkInfo> adjusted_links;
|
||||
size_t pos = 0;
|
||||
|
||||
for (const auto& link : links) {
|
||||
marked_text += text.substr(pos, link.start_pos - pos);
|
||||
size_t link_start = marked_text.length();
|
||||
|
||||
marked_text += text.substr(link.start_pos, link.end_pos - link.start_pos);
|
||||
|
||||
// Add marker [N] only for links
|
||||
if (link.link_index >= 0) {
|
||||
std::string marker = "[" + std::to_string(link.link_index + 1) + "]";
|
||||
marked_text += marker;
|
||||
}
|
||||
|
||||
size_t link_end = marked_text.length();
|
||||
|
||||
adjusted_links.push_back({link_start, link_end, link.link_index, link.field_index});
|
||||
pos = link.end_pos;
|
||||
}
|
||||
|
||||
if (pos < text.length()) {
|
||||
marked_text += text.substr(pos);
|
||||
}
|
||||
|
||||
// 2. Wrap text
|
||||
size_t line_start_idx = 0;
|
||||
size_t current_line_width = 0;
|
||||
size_t last_space_idx = std::string::npos;
|
||||
|
||||
for (size_t i = 0; i <= marked_text.length(); ++i) {
|
||||
bool is_break = (i == marked_text.length() || marked_text[i] == ' ' || marked_text[i] == '\n');
|
||||
|
||||
if (is_break) {
|
||||
std::string word = marked_text.substr(
|
||||
(last_space_idx == std::string::npos) ? line_start_idx : last_space_idx + 1,
|
||||
i - ((last_space_idx == std::string::npos) ? line_start_idx : last_space_idx + 1)
|
||||
);
|
||||
|
||||
size_t word_width = display_width(word);
|
||||
size_t space_width = (current_line_width == 0) ? 0 : 1;
|
||||
|
||||
if (current_line_width + space_width + word_width > static_cast<size_t>(max_width)) {
|
||||
// Wrap
|
||||
if (current_line_width > 0) {
|
||||
// End current line at last space
|
||||
std::string line_str = marked_text.substr(line_start_idx, last_space_idx - line_start_idx);
|
||||
|
||||
// Collect links
|
||||
std::vector<LinkInfo> line_links;
|
||||
for (const auto& link : adjusted_links) {
|
||||
// Check overlap
|
||||
size_t link_s = link.start_pos;
|
||||
size_t link_e = link.end_pos;
|
||||
size_t line_s = line_start_idx;
|
||||
size_t line_e = last_space_idx;
|
||||
|
||||
if (link_s < line_e && link_e > line_s) {
|
||||
size_t start = (link_s > line_s) ? link_s - line_s : 0;
|
||||
size_t end = (link_e < line_e) ? link_e - line_s : line_e - line_s;
|
||||
line_links.push_back({start, end, link.link_index, link.field_index});
|
||||
}
|
||||
}
|
||||
result.push_back({line_str, line_links});
|
||||
|
||||
// Start new line
|
||||
line_start_idx = last_space_idx + 1;
|
||||
current_line_width = word_width;
|
||||
last_space_idx = i;
|
||||
} else {
|
||||
// Word itself is too long, force break (not implemented for simplicity, just overflow)
|
||||
last_space_idx = i;
|
||||
current_line_width += space_width + word_width;
|
||||
}
|
||||
} else {
|
||||
current_line_width += space_width + word_width;
|
||||
last_space_idx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last line
|
||||
if (line_start_idx < marked_text.length()) {
|
||||
std::string line_str = marked_text.substr(line_start_idx);
|
||||
std::vector<LinkInfo> line_links;
|
||||
for (const auto& link : adjusted_links) {
|
||||
size_t link_s = link.start_pos;
|
||||
size_t link_e = link.end_pos;
|
||||
size_t line_s = line_start_idx;
|
||||
size_t line_e = marked_text.length();
|
||||
|
||||
if (link_s < line_e && link_e > line_s) {
|
||||
size_t start = (link_s > line_s) ? link_s - line_s : 0;
|
||||
size_t end = (link_e < line_e) ? link_e - line_s : line_e - line_s;
|
||||
line_links.push_back({start, end, link.link_index, link.field_index});
|
||||
}
|
||||
}
|
||||
result.push_back({line_str, line_links});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TextRenderer::Impl
|
||||
// ============================================================================
|
||||
|
||||
class TextRenderer::Impl {
|
||||
public:
|
||||
RenderConfig config;
|
||||
|
||||
struct InlineContent {
|
||||
std::string text;
|
||||
std::vector<InlineLink> links;
|
||||
};
|
||||
|
||||
RenderedLine create_empty_line() {
|
||||
RenderedLine line;
|
||||
line.text = "";
|
||||
line.color_pair = COLOR_NORMAL;
|
||||
line.is_bold = false;
|
||||
line.is_link = false;
|
||||
line.link_index = -1;
|
||||
return line;
|
||||
}
|
||||
|
||||
std::vector<RenderedLine> render_tree(const DocumentTree& tree, int screen_width) {
|
||||
std::vector<RenderedLine> lines;
|
||||
if (!tree.root) return lines;
|
||||
|
||||
RenderContext ctx;
|
||||
ctx.screen_width = config.center_content ? std::min(config.max_width, screen_width) : screen_width;
|
||||
ctx.current_indent = 0;
|
||||
ctx.nesting_level = 0;
|
||||
ctx.color_pair = COLOR_NORMAL;
|
||||
ctx.is_bold = false;
|
||||
|
||||
render_node(tree.root.get(), ctx, lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
void render_node(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
if (!node || !node->should_render()) return;
|
||||
|
||||
if (node->is_block_element()) {
|
||||
if (node->tag_name == "table") {
|
||||
render_table(node, ctx, lines);
|
||||
} else {
|
||||
switch (node->element_type) {
|
||||
case ElementType::HEADING1:
|
||||
case ElementType::HEADING2:
|
||||
case ElementType::HEADING3:
|
||||
render_heading(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::PARAGRAPH:
|
||||
render_paragraph(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::HORIZONTAL_RULE:
|
||||
render_hr(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::CODE_BLOCK:
|
||||
render_code_block(node, ctx, lines);
|
||||
break;
|
||||
case ElementType::BLOCKQUOTE:
|
||||
render_blockquote(node, ctx, lines);
|
||||
break;
|
||||
default:
|
||||
if (node->tag_name == "ul" || node->tag_name == "ol") {
|
||||
render_list(node, ctx, lines);
|
||||
} else {
|
||||
for (auto& child : node->children) {
|
||||
render_node(child.get(), ctx, lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node->node_type == NodeType::DOCUMENT || node->node_type == NodeType::ELEMENT) {
|
||||
for (auto& child : node->children) {
|
||||
render_node(child.get(), ctx, lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Table Rendering
|
||||
// ========================================================================
|
||||
|
||||
struct CellData {
|
||||
std::vector<std::string> lines; // Wrapped lines
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int colspan = 1;
|
||||
int rowspan = 1;
|
||||
bool is_header = false;
|
||||
};
|
||||
|
||||
void render_table(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
// Simplified table rendering (skipping complex grid for brevity, reverting to previous improved logic)
|
||||
// Note: For brevity in this tool call, reusing the logic from previous step but integrated with form fields?
|
||||
// Actually, let's keep the logic I wrote before.
|
||||
|
||||
// 1. Collect Table Data
|
||||
std::vector<std::vector<CellData>> grid;
|
||||
std::vector<int> col_widths;
|
||||
int max_cols = 0;
|
||||
|
||||
for (auto& child : node->children) {
|
||||
if (child->tag_name == "tr") {
|
||||
std::vector<CellData> row;
|
||||
for (auto& cell : child->children) {
|
||||
if (cell->tag_name == "td" || cell->tag_name == "th") {
|
||||
CellData data;
|
||||
data.is_header = (cell->tag_name == "th");
|
||||
data.colspan = cell->colspan > 0 ? cell->colspan : 1;
|
||||
InlineContent content = collect_inline_content(cell.get());
|
||||
std::string clean = clean_text(content.text);
|
||||
data.lines.push_back(clean);
|
||||
data.width = display_width(clean);
|
||||
data.height = 1;
|
||||
row.push_back(data);
|
||||
}
|
||||
}
|
||||
if (!row.empty()) {
|
||||
grid.push_back(row);
|
||||
max_cols = std::max(max_cols, (int)row.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (grid.empty()) return;
|
||||
|
||||
col_widths.assign(max_cols, 0);
|
||||
for (const auto& row : grid) {
|
||||
for (size_t i = 0; i < row.size(); ++i) {
|
||||
if (i < col_widths.size()) {
|
||||
col_widths[i] = std::max(col_widths[i], row[i].width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int total_width = std::accumulate(col_widths.begin(), col_widths.end(), 0);
|
||||
int available_width = ctx.screen_width - 4;
|
||||
available_width = std::max(10, available_width);
|
||||
|
||||
if (total_width > available_width) {
|
||||
double ratio = (double)available_width / total_width;
|
||||
for (auto& w : col_widths) {
|
||||
w = std::max(3, (int)(w * ratio));
|
||||
}
|
||||
}
|
||||
|
||||
std::string border_line = "+";
|
||||
for (int w : col_widths) {
|
||||
border_line += std::string(w + 2, '-') + "+";
|
||||
}
|
||||
|
||||
RenderedLine border;
|
||||
border.text = border_line;
|
||||
border.color_pair = COLOR_DIM;
|
||||
lines.push_back(border);
|
||||
|
||||
for (auto& row : grid) {
|
||||
int max_row_height = 0;
|
||||
std::vector<std::vector<std::string>> row_wrapped_content;
|
||||
|
||||
for (size_t i = 0; i < row.size(); ++i) {
|
||||
if (i >= col_widths.size()) break;
|
||||
|
||||
int cell_w = col_widths[i];
|
||||
std::string raw_text = row[i].lines[0];
|
||||
auto wrapped = wrap_text_with_links(raw_text, cell_w, {}); // Simplified: no links in table for now
|
||||
|
||||
std::vector<std::string> cell_lines;
|
||||
for(auto& p : wrapped) cell_lines.push_back(p.first);
|
||||
if (cell_lines.empty()) cell_lines.push_back("");
|
||||
|
||||
row_wrapped_content.push_back(cell_lines);
|
||||
max_row_height = std::max(max_row_height, (int)cell_lines.size());
|
||||
}
|
||||
|
||||
for (int h = 0; h < max_row_height; ++h) {
|
||||
std::string line_str = "|";
|
||||
for (size_t i = 0; i < col_widths.size(); ++i) {
|
||||
int w = col_widths[i];
|
||||
std::string content = "";
|
||||
if (i < row_wrapped_content.size() && h < (int)row_wrapped_content[i].size()) {
|
||||
content = row_wrapped_content[i][h];
|
||||
}
|
||||
line_str += " " + pad_string(content, w) + " |";
|
||||
}
|
||||
|
||||
RenderedLine rline;
|
||||
rline.text = line_str;
|
||||
rline.color_pair = COLOR_NORMAL;
|
||||
lines.push_back(rline);
|
||||
}
|
||||
|
||||
lines.push_back(border);
|
||||
}
|
||||
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Other Elements
|
||||
// ========================================================================
|
||||
|
||||
void render_heading(DomNode* node, RenderContext& /*ctx*/, std::vector<RenderedLine>& lines) {
|
||||
InlineContent content = collect_inline_content(node);
|
||||
if (content.text.empty()) return;
|
||||
|
||||
RenderedLine line;
|
||||
line.text = clean_text(content.text);
|
||||
line.color_pair = COLOR_HEADING1;
|
||||
line.is_bold = true;
|
||||
lines.push_back(line);
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_paragraph(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
InlineContent content = collect_inline_content(node);
|
||||
std::string text = clean_text(content.text);
|
||||
if (text.empty()) return;
|
||||
|
||||
auto wrapped = wrap_text_with_links(text, ctx.screen_width, content.links);
|
||||
for (const auto& [line_text, link_infos] : wrapped) {
|
||||
RenderedLine line;
|
||||
line.text = line_text;
|
||||
line.color_pair = COLOR_NORMAL;
|
||||
if (!link_infos.empty()) {
|
||||
line.is_link = true; // Kept for compatibility, though we use interactive_ranges
|
||||
line.link_index = -1;
|
||||
|
||||
for (const auto& li : link_infos) {
|
||||
InteractiveRange range;
|
||||
range.start = li.start_pos;
|
||||
range.end = li.end_pos;
|
||||
range.link_index = li.link_index;
|
||||
range.field_index = li.field_index;
|
||||
line.interactive_ranges.push_back(range);
|
||||
|
||||
if (li.link_index >= 0) line.link_index = li.link_index; // Heuristic: set main link index to first link
|
||||
}
|
||||
}
|
||||
lines.push_back(line);
|
||||
}
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_list(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
bool is_ordered = (node->tag_name == "ol");
|
||||
int count = 1;
|
||||
|
||||
for(auto& child : node->children) {
|
||||
if(child->tag_name == "li") {
|
||||
InlineContent content = collect_inline_content(child.get());
|
||||
std::string prefix = is_ordered ? std::to_string(count++) + ". " : "* ";
|
||||
|
||||
auto wrapped = wrap_text_with_links(clean_text(content.text), ctx.screen_width - 4, content.links);
|
||||
|
||||
bool first = true;
|
||||
for(const auto& [txt, links_info] : wrapped) {
|
||||
RenderedLine line;
|
||||
line.text = (first ? prefix : " ") + txt;
|
||||
line.color_pair = COLOR_NORMAL;
|
||||
|
||||
if(!links_info.empty()) {
|
||||
line.is_link = true;
|
||||
for(const auto& l : links_info) {
|
||||
InteractiveRange range;
|
||||
range.start = l.start_pos + prefix.length();
|
||||
range.end = l.end_pos + prefix.length();
|
||||
range.link_index = l.link_index;
|
||||
range.field_index = l.field_index;
|
||||
line.interactive_ranges.push_back(range);
|
||||
}
|
||||
}
|
||||
lines.push_back(line);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_hr(DomNode* /*node*/, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
RenderedLine line;
|
||||
line.text = std::string(ctx.screen_width, '-');
|
||||
line.color_pair = COLOR_DIM;
|
||||
lines.push_back(line);
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_code_block(DomNode* node, RenderContext& /*ctx*/, std::vector<RenderedLine>& lines) {
|
||||
std::string text = node->get_all_text();
|
||||
std::istringstream iss(text);
|
||||
std::string line_str;
|
||||
while(std::getline(iss, line_str)) {
|
||||
RenderedLine line;
|
||||
line.text = " " + line_str;
|
||||
line.color_pair = COLOR_DIM;
|
||||
lines.push_back(line);
|
||||
}
|
||||
lines.push_back(create_empty_line());
|
||||
}
|
||||
|
||||
void render_blockquote(DomNode* node, RenderContext& ctx, std::vector<RenderedLine>& lines) {
|
||||
for (auto& child : node->children) {
|
||||
render_node(child.get(), ctx, lines);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Collect Inline Content
|
||||
InlineContent collect_inline_content(DomNode* node) {
|
||||
InlineContent result;
|
||||
for (auto& child : node->children) {
|
||||
if (child->node_type == NodeType::TEXT) {
|
||||
result.text += child->text_content;
|
||||
} else if (child->element_type == ElementType::LINK && child->link_index >= 0) {
|
||||
InlineLink link;
|
||||
link.text = child->get_all_text();
|
||||
link.url = child->href;
|
||||
link.link_index = child->link_index;
|
||||
link.field_index = -1;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += link.text;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::INPUT) {
|
||||
std::string repr;
|
||||
if (child->input_type == "checkbox") {
|
||||
repr = child->checked ? "[x]" : "[ ]";
|
||||
} else if (child->input_type == "radio") {
|
||||
repr = child->checked ? "(*)" : "( )";
|
||||
} else if (child->input_type == "submit" || child->input_type == "button") {
|
||||
repr = "[" + (child->value.empty() ? "Submit" : child->value) + "]";
|
||||
} else {
|
||||
// text, password, etc.
|
||||
std::string val = child->value.empty() ? child->placeholder : child->value;
|
||||
if (val.empty()) val = "________";
|
||||
repr = "[" + val + "]";
|
||||
}
|
||||
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::BUTTON) {
|
||||
std::string repr = "[" + (child->value.empty() ? (child->name.empty() ? "Button" : child->name) : child->value) + "]";
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::TEXTAREA) {
|
||||
std::string repr = "[ " + (child->value.empty() ? "Textarea" : child->value) + " ]";
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::SELECT) {
|
||||
std::string repr = "[ Select ]"; // Simplified
|
||||
InlineLink link;
|
||||
link.text = repr;
|
||||
link.link_index = -1;
|
||||
link.field_index = child->field_index;
|
||||
link.start_pos = result.text.length();
|
||||
result.text += repr;
|
||||
link.end_pos = result.text.length();
|
||||
result.links.push_back(link);
|
||||
} else if (child->element_type == ElementType::IMAGE) {
|
||||
// Render image placeholder
|
||||
std::string repr = "[IMG";
|
||||
if (!child->alt_text.empty()) {
|
||||
repr += ": " + child->alt_text;
|
||||
}
|
||||
repr += "]";
|
||||
|
||||
result.text += repr;
|
||||
// Images are not necessarily links unless wrapped in <a>.
|
||||
// If wrapped in <a>, the parent processing handles the link range.
|
||||
} else {
|
||||
InlineContent nested = collect_inline_content(child.get());
|
||||
size_t offset = result.text.length();
|
||||
result.text += nested.text;
|
||||
for(auto l : nested.links) {
|
||||
l.start_pos += offset;
|
||||
l.end_pos += offset;
|
||||
result.links.push_back(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Legacy support
|
||||
std::vector<RenderedLine> render_legacy(const ParsedDocument& /*doc*/, int /*screen_width*/) {
|
||||
return {}; // Not used anymore
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Public Interface
|
||||
// ============================================================================
|
||||
|
||||
TextRenderer::TextRenderer() : pImpl(std::make_unique<Impl>()) {}
|
||||
TextRenderer::~TextRenderer() = default;
|
||||
|
||||
std::vector<RenderedLine> TextRenderer::render_tree(const DocumentTree& tree, int screen_width) {
|
||||
return pImpl->render_tree(tree, screen_width);
|
||||
}
|
||||
|
||||
std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int screen_width) {
|
||||
return pImpl->render_legacy(doc, screen_width);
|
||||
}
|
||||
|
||||
void TextRenderer::set_config(const RenderConfig& config) {
|
||||
pImpl->config = config;
|
||||
}
|
||||
|
||||
RenderConfig TextRenderer::get_config() const {
|
||||
return pImpl->config;
|
||||
}
|
||||
|
||||
void init_color_scheme() {
|
||||
init_pair(COLOR_NORMAL, COLOR_WHITE, COLOR_BLACK);
|
||||
init_pair(COLOR_HEADING1, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_HEADING2, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_HEADING3, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_LINK, COLOR_YELLOW, COLOR_BLACK);
|
||||
init_pair(COLOR_LINK_ACTIVE, COLOR_YELLOW, COLOR_BLUE);
|
||||
init_pair(COLOR_STATUS_BAR, COLOR_BLACK, COLOR_WHITE);
|
||||
init_pair(COLOR_URL_BAR, COLOR_CYAN, COLOR_BLACK);
|
||||
init_pair(COLOR_SEARCH_HIGHLIGHT, COLOR_BLACK, COLOR_YELLOW);
|
||||
init_pair(COLOR_DIM, COLOR_WHITE, COLOR_BLACK);
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include "html_parser.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <curses.h>
|
||||
|
||||
// Forward declarations
|
||||
struct DocumentTree;
|
||||
struct DomNode;
|
||||
|
||||
struct InteractiveRange {
|
||||
size_t start;
|
||||
size_t end;
|
||||
int link_index = -1;
|
||||
int field_index = -1;
|
||||
};
|
||||
|
||||
struct RenderedLine {
|
||||
std::string text;
|
||||
int color_pair;
|
||||
bool is_bold;
|
||||
bool is_link;
|
||||
int link_index;
|
||||
std::vector<InteractiveRange> interactive_ranges;
|
||||
};
|
||||
|
||||
// Unicode装饰字符
|
||||
namespace UnicodeChars {
|
||||
// 框线字符 (Box Drawing)
|
||||
constexpr const char* DBL_HORIZONTAL = "═";
|
||||
constexpr const char* DBL_VERTICAL = "║";
|
||||
constexpr const char* DBL_TOP_LEFT = "╔";
|
||||
constexpr const char* DBL_TOP_RIGHT = "╗";
|
||||
constexpr const char* DBL_BOTTOM_LEFT = "╚";
|
||||
constexpr const char* DBL_BOTTOM_RIGHT = "╝";
|
||||
|
||||
constexpr const char* SGL_HORIZONTAL = "─";
|
||||
constexpr const char* SGL_VERTICAL = "│";
|
||||
constexpr const char* SGL_TOP_LEFT = "┌";
|
||||
constexpr const char* SGL_TOP_RIGHT = "┐";
|
||||
constexpr const char* SGL_BOTTOM_LEFT = "└";
|
||||
constexpr const char* SGL_BOTTOM_RIGHT = "┘";
|
||||
constexpr const char* SGL_CROSS = "┼";
|
||||
constexpr const char* SGL_T_DOWN = "┬";
|
||||
constexpr const char* SGL_T_UP = "┴";
|
||||
constexpr const char* SGL_T_RIGHT = "├";
|
||||
constexpr const char* SGL_T_LEFT = "┤";
|
||||
|
||||
constexpr const char* HEAVY_HORIZONTAL = "━";
|
||||
constexpr const char* HEAVY_VERTICAL = "┃";
|
||||
|
||||
// 列表符号
|
||||
constexpr const char* BULLET = "•";
|
||||
constexpr const char* CIRCLE = "◦";
|
||||
constexpr const char* SQUARE = "▪";
|
||||
constexpr const char* TRIANGLE = "‣";
|
||||
|
||||
// 装饰符号
|
||||
constexpr const char* SECTION = "§";
|
||||
constexpr const char* PARAGRAPH = "¶";
|
||||
constexpr const char* ARROW_RIGHT = "→";
|
||||
constexpr const char* ELLIPSIS = "…";
|
||||
}
|
||||
|
||||
struct RenderConfig {
|
||||
// 布局设置
|
||||
int max_width = 80; // 最大内容宽度
|
||||
int margin_left = 0; // 左边距
|
||||
bool center_content = false; // 内容居中
|
||||
int paragraph_spacing = 1; // 段落间距
|
||||
|
||||
// 响应式宽度设置
|
||||
bool responsive_width = true; // 启用响应式宽度
|
||||
int min_width = 60; // 最小内容宽度
|
||||
int max_content_width = 100; // 最大内容宽度
|
||||
int small_screen_threshold = 80; // 小屏阈值
|
||||
int large_screen_threshold = 120;// 大屏阈值
|
||||
|
||||
// 链接设置
|
||||
bool show_link_indicators = false; // 不显示[N]编号
|
||||
bool inline_links = true; // 内联链接(仅颜色)
|
||||
|
||||
// 视觉样式
|
||||
bool use_unicode_boxes = true; // 使用Unicode框线
|
||||
bool use_fancy_bullets = true; // 使用精美列表符号
|
||||
bool show_decorative_lines = true; // 显示装饰线
|
||||
|
||||
// 标题样式
|
||||
bool h1_use_double_border = true; // H1使用双线框
|
||||
bool h2_use_single_border = true; // H2使用单线框
|
||||
bool h3_use_underline = true; // H3使用下划线
|
||||
};
|
||||
|
||||
// 渲染上下文
|
||||
struct RenderContext {
|
||||
int screen_width; // 终端宽度
|
||||
int current_indent; // 当前缩进级别
|
||||
int nesting_level; // 列表嵌套层级
|
||||
int color_pair; // 当前颜色
|
||||
bool is_bold; // 是否加粗
|
||||
};
|
||||
|
||||
class TextRenderer {
|
||||
public:
|
||||
TextRenderer();
|
||||
~TextRenderer();
|
||||
|
||||
// 新接口:从DOM树渲染
|
||||
std::vector<RenderedLine> render_tree(const DocumentTree& tree, int screen_width);
|
||||
|
||||
// 旧接口:向后兼容
|
||||
std::vector<RenderedLine> render(const ParsedDocument& doc, int screen_width);
|
||||
|
||||
void set_config(const RenderConfig& config);
|
||||
RenderConfig get_config() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> pImpl;
|
||||
};
|
||||
|
||||
enum ColorScheme {
|
||||
COLOR_NORMAL = 1,
|
||||
COLOR_HEADING1,
|
||||
COLOR_HEADING2,
|
||||
COLOR_HEADING3,
|
||||
COLOR_LINK,
|
||||
COLOR_LINK_ACTIVE,
|
||||
COLOR_STATUS_BAR,
|
||||
COLOR_URL_BAR,
|
||||
COLOR_SEARCH_HIGHLIGHT,
|
||||
COLOR_DIM
|
||||
};
|
||||
|
||||
void init_color_scheme();
|
||||
7988
src/utils/stb_image.h
Normal file
7988
src/utils/stb_image.h
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,24 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Inline Links</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Page for Inline Links</h1>
|
||||
|
||||
<p>This is a paragraph with an <a href="https://example.com">inline link</a> in the middle of the text. You should be able to see the link highlighted directly in the text.</p>
|
||||
|
||||
<p>Here is another paragraph with multiple links: <a href="https://google.com">Google</a> and <a href="https://github.com">GitHub</a> are both popular websites.</p>
|
||||
|
||||
<p>This paragraph has a longer link text: <a href="https://en.wikipedia.org">Wikipedia is a free online encyclopedia</a> that anyone can edit.</p>
|
||||
|
||||
<h2>More Examples</h2>
|
||||
|
||||
<p>Press Tab to navigate between links, and Enter to follow them. The links should be <a href="https://example.com/test1">highlighted</a> directly in the text, not listed separately at the bottom.</p>
|
||||
|
||||
<ul>
|
||||
<li>List item with <a href="https://news.ycombinator.com">Hacker News</a></li>
|
||||
<li>Another item with <a href="https://reddit.com">Reddit</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>POST Form Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Form Method Test</h1>
|
||||
|
||||
<h2>GET Form</h2>
|
||||
<form action="https://httpbin.org/get" method="get">
|
||||
<p>Name: <input type="text" name="name" value="John"></p>
|
||||
<p>Email: <input type="text" name="email" value="john@example.com"></p>
|
||||
<p><input type="submit" value="Submit GET"></p>
|
||||
</form>
|
||||
|
||||
<h2>POST Form</h2>
|
||||
<form action="https://httpbin.org/post" method="post">
|
||||
<p>Username: <input type="text" name="username" value="testuser"></p>
|
||||
<p>Password: <input type="password" name="password" value="secret123"></p>
|
||||
<p>Message: <input type="text" name="message" value="Hello World"></p>
|
||||
<p><input type="submit" value="Submit POST"></p>
|
||||
</form>
|
||||
|
||||
<h2>Form with Special Characters</h2>
|
||||
<form action="https://httpbin.org/post" method="post">
|
||||
<p>Text: <input type="text" name="text" value="Hello & goodbye!"></p>
|
||||
<p>Code: <input type="text" name="code" value="a=b&c=d"></p>
|
||||
<p><input type="submit" value="Submit"></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<html>
|
||||
<body>
|
||||
<h1>Table Test</h1>
|
||||
<p>This is a paragraph before the table.</p>
|
||||
<table border="1">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>Item One</td>
|
||||
<td>This is a long description for item one to test wrapping.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>Item Two</td>
|
||||
<td>Short desc.</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>This is a paragraph after the table.</p>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue