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:
m1ngsama 2025-12-27 17:59:05 +08:00
parent a469f79a1e
commit 2878b42d36
15 changed files with 9010 additions and 2452 deletions

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.15) 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标准 # C++17标准
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
@ -23,20 +23,43 @@ pkg_check_modules(GUMBO REQUIRED gumbo)
# 包含目录 # 包含目录
include_directories( include_directories(
${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/src/core
${CMAKE_SOURCE_DIR}/src/render ${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 ${CMAKE_SOURCE_DIR}/src/utils
${CURL_INCLUDE_DIRS} ${CURL_INCLUDE_DIRS}
${CURSES_INCLUDE_DIRS} ${CURSES_INCLUDE_DIRS}
${GUMBO_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 add_executable(test_terminal
src/render/terminal.cpp src/render/terminal.cpp
tests/test_terminal.cpp tests/test_terminal.cpp
@ -46,8 +69,7 @@ target_link_libraries(test_terminal
${CURSES_LIBRARIES} ${CURSES_LIBRARIES}
) )
# ==================== Renderer 测试程序 ==================== # Renderer 测试
add_executable(test_renderer add_executable(test_renderer
src/render/terminal.cpp src/render/terminal.cpp
src/render/renderer.cpp src/render/renderer.cpp
@ -59,8 +81,7 @@ target_link_libraries(test_renderer
${CURSES_LIBRARIES} ${CURSES_LIBRARIES}
) )
# ==================== Layout 测试程序 ==================== # Layout 测试
add_executable(test_layout add_executable(test_layout
src/render/terminal.cpp src/render/terminal.cpp
src/render/renderer.cpp src/render/renderer.cpp
@ -81,57 +102,7 @@ target_link_libraries(test_layout
${GUMBO_LIBRARIES} ${GUMBO_LIBRARIES}
) )
# ==================== TUT 2.0 主程序 ==================== # HTTP 异步测试
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 异步测试程序 ====================
add_executable(test_http_async add_executable(test_http_async
src/http_client.cpp src/http_client.cpp
tests/test_http_async.cpp tests/test_http_async.cpp
@ -141,8 +112,7 @@ target_link_libraries(test_http_async
CURL::libcurl CURL::libcurl
) )
# ==================== HTML 解析测试程序 ==================== # HTML 解析测试
add_executable(test_html_parse add_executable(test_html_parse
src/html_parser.cpp src/html_parser.cpp
src/dom_tree.cpp src/dom_tree.cpp
@ -157,8 +127,7 @@ target_link_libraries(test_html_parse
${GUMBO_LIBRARIES} ${GUMBO_LIBRARIES}
) )
# ==================== 书签测试程序 ==================== # 书签测试
add_executable(test_bookmark add_executable(test_bookmark
src/bookmark.cpp src/bookmark.cpp
tests/test_bookmark.cpp tests/test_bookmark.cpp

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,20 @@
#include "http_client.h" #include "http_client.h"
#include "html_parser.h" #include "html_parser.h"
#include "text_renderer.h"
#include "input_handler.h" #include "input_handler.h"
#include "render/terminal.h"
#include "render/renderer.h"
#include "render/layout.h"
#include <string> #include <string>
#include <vector> #include <vector>
#include <memory> #include <memory>
/**
* Browser - TUT
*
* 使 Terminal + FrameBuffer + Renderer + LayoutEngine
* True Color, Unicode,
*/
class Browser { class Browser {
public: public:
Browser(); Browser();

View file

@ -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;
}

View file

@ -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;
};

View file

@ -48,7 +48,12 @@ bool DomNode::is_block_element() const {
tag_name == "pre" || tag_name == "hr" || tag_name == "pre" || tag_name == "hr" ||
tag_name == "table" || tag_name == "tr" || tag_name == "table" || tag_name == "tr" ||
tag_name == "th" || tag_name == "td" || 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 DomNode::get_all_text() const {
std::string result; std::string result;
// 过滤不应该提取文本的元素
if (!should_render()) {
return "";
}
if (node_type == NodeType::TEXT) { if (node_type == NodeType::TEXT) {
result = text_content; result = text_content;
} else { } else {

View file

@ -4,7 +4,7 @@
void print_usage(const char* prog_name) { void print_usage(const char* prog_name) {
std::cout << "TUT - Terminal User Interface Browser\n" 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" << "Usage: " << prog_name << " [URL]\n\n"
<< "If no URL is provided, the browser will start with a help page.\n\n" << "If no URL is provided, the browser will start with a help page.\n\n"
<< "Examples:\n" << "Examples:\n"
@ -44,4 +44,3 @@ int main(int argc, char* argv[]) {
return 0; return 0;
} }

View file

@ -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;
}

View file

@ -77,20 +77,6 @@ void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<La
return; 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 || if (node->element_type == ElementType::INPUT ||
node->element_type == ElementType::BUTTON || node->element_type == ElementType::BUTTON ||
@ -106,10 +92,86 @@ void LayoutEngine::layout_node(const DomNode* node, Context& ctx, std::vector<La
return; return;
} }
// 处理块级元素
if (node->is_block_element()) { if (node->is_block_element()) {
layout_block_element(node, ctx, blocks); 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) { 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); 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) { void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
if (!node) return; if (!node) return;
@ -502,10 +599,21 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
int link_idx = node->link_index; 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) { 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; StyledSpan span;
span.text = child->text_content; span.text = text;
span.fg = fg; span.fg = fg;
span.attrs = attrs; span.attrs = attrs;
span.link_index = link_idx; span.link_index = link_idx;
@ -516,6 +624,16 @@ void LayoutEngine::collect_inline_content(const DomNode* node, Context& ctx, std
spans.push_back(span); spans.push_back(span);
} else if (!child->is_block_element()) { } 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); 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) { void LayoutEngine::layout_text(const DomNode* node, Context& ctx, std::vector<StyledSpan>& spans) {
if (!node || node->text_content.empty()) return; 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; StyledSpan span;
span.text = node->text_content; span.text = text;
span.fg = colors::FG_PRIMARY; span.fg = colors::FG_PRIMARY;
if (ctx.in_blockquote) { if (ctx.in_blockquote) {
@ -546,12 +673,13 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
LayoutLine current_line; LayoutLine current_line;
current_line.indent = indent; current_line.indent = indent;
size_t current_width = 0; size_t current_width = 0;
bool is_line_start = true; // 整行的开始标记
for (const auto& span : spans) { for (const auto& span : spans) {
// 分词处理 // 分词处理
std::istringstream iss(span.text); std::istringstream iss(span.text);
std::string word; std::string word;
bool first_word = true; bool first_word_in_span = true;
while (iss >> word) { while (iss >> word) {
size_t word_width = Unicode::display_width(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 = LayoutLine();
current_line.indent = indent; current_line.indent = indent;
current_width = 0; current_width = 0;
first_word = true; is_line_start = true;
} }
// 添加空格(如果不是行首) // 添加空格(如果不是行首且不是第一个单词)
if (current_width > 0 && !first_word) { // 需要在不同 span 之间也添加空格
if (!current_line.spans.empty()) { 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_line.spans.back().text += " ";
current_width += 1; current_width += 1;
} }
@ -581,7 +717,8 @@ std::vector<LayoutLine> LayoutEngine::wrap_text(const std::vector<StyledSpan>& s
word_span.text = word; word_span.text = word;
current_line.spans.push_back(word_span); current_line.spans.push_back(word_span);
current_width += word_width; current_width += word_width;
first_word = false; is_line_start = false;
first_word_in_span = false;
} }
} }

View file

@ -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);
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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>

View file

@ -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>