From 18859eef478fe8e41827104929aea2a219fbe676 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Sat, 27 Dec 2025 15:47:09 +0800 Subject: [PATCH] feat: Add async HTTP requests with non-blocking loading - Implement curl multi interface for async HTTP in HttpClient - Add loading spinner animation during page load - Support Esc key to cancel loading - Non-blocking main loop with 50ms polling - Loading state management (IDLE, LOADING_PAGE, LOADING_IMAGES) - Preserve sync API for backward compatibility --- src/browser_v2.cpp | 207 ++++++++++++++++++++++++++++++++++++++++++-- src/http_client.cpp | 157 ++++++++++++++++++++++++++++++++- src/http_client.h | 19 ++++ 3 files changed, 376 insertions(+), 7 deletions(-) diff --git a/src/browser_v2.cpp b/src/browser_v2.cpp index 644f45b..7c4c10b 100644 --- a/src/browser_v2.cpp +++ b/src/browser_v2.cpp @@ -15,6 +15,19 @@ 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; @@ -70,6 +83,13 @@ public: 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; @@ -170,6 +190,162 @@ public: 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(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(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(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) { @@ -357,33 +533,33 @@ public: case Action::FOLLOW_LINK: if (active_link >= 0 && active_link < static_cast(current_tree.links.size())) { - load_page(current_tree.links[active_link].url); + start_async_load(current_tree.links[active_link].url); } break; case Action::GO_BACK: if (history_pos > 0) { history_pos--; - load_page(history[history_pos]); + start_async_load(history[history_pos]); } break; case Action::GO_FORWARD: if (history_pos < static_cast(history.size()) - 1) { history_pos++; - load_page(history[history_pos]); + start_async_load(history[history_pos]); } break; case Action::OPEN_URL: if (!result.text.empty()) { - load_page(result.text); + start_async_load(result.text); } break; case Action::REFRESH: if (!current_url.empty()) { - load_page(current_url, true); // 强制刷新,跳过缓存 + start_async_load(current_url, true); // 强制刷新,跳过缓存 } break; @@ -724,15 +900,20 @@ void BrowserV2::run(const std::string& initial_url) { } if (!initial_url.empty()) { - load_url(initial_url); + 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; @@ -742,6 +923,20 @@ void BrowserV2::run(const std::string& initial_url) { 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; diff --git a/src/http_client.cpp b/src/http_client.cpp index 98be802..8ae2db4 100644 --- a/src/http_client.cpp +++ b/src/http_client.cpp @@ -25,8 +25,15 @@ public: bool follow_redirects; std::string cookie_file; + // 异步请求相关 + CURLM* multi_handle = nullptr; + CURL* async_easy = nullptr; + AsyncState async_state = AsyncState::IDLE; + std::string async_response_body; + HttpResponse async_result; + Impl() : timeout(30), - user_agent("TUT-Browser/1.0 (Terminal User Interface Browser)"), + user_agent("TUT-Browser/2.0 (Terminal User Interface Browser)"), follow_redirects(true) { curl = curl_easy_init(); if (!curl) { @@ -36,13 +43,59 @@ public: curl_easy_setopt(curl, CURLOPT_COOKIEFILE, ""); // Enable automatic decompression of supported encodings (gzip, deflate, etc.) curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, ""); + + // 初始化multi handle用于异步请求 + multi_handle = curl_multi_init(); + if (!multi_handle) { + throw std::runtime_error("Failed to initialize CURL multi handle"); + } } ~Impl() { + // 清理异步请求 + cleanup_async(); + + if (multi_handle) { + curl_multi_cleanup(multi_handle); + } if (curl) { curl_easy_cleanup(curl); } } + + void cleanup_async() { + if (async_easy) { + curl_multi_remove_handle(multi_handle, async_easy); + curl_easy_cleanup(async_easy); + async_easy = nullptr; + } + async_state = AsyncState::IDLE; + async_response_body.clear(); + } + + void setup_easy_handle(CURL* handle, const std::string& url) { + curl_easy_setopt(handle, CURLOPT_URL, url.c_str()); + curl_easy_setopt(handle, CURLOPT_TIMEOUT, timeout); + curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT, 10L); + curl_easy_setopt(handle, CURLOPT_USERAGENT, user_agent.c_str()); + + if (follow_redirects) { + curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(handle, CURLOPT_MAXREDIRS, 10L); + } + + curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L); + + if (!cookie_file.empty()) { + curl_easy_setopt(handle, CURLOPT_COOKIEFILE, cookie_file.c_str()); + curl_easy_setopt(handle, CURLOPT_COOKIEJAR, cookie_file.c_str()); + } else { + curl_easy_setopt(handle, CURLOPT_COOKIEFILE, ""); + } + + curl_easy_setopt(handle, CURLOPT_ACCEPT_ENCODING, ""); + } }; HttpClient::HttpClient() : pImpl(std::make_unique()) {} @@ -297,4 +350,106 @@ void HttpClient::set_follow_redirects(bool follow) { void HttpClient::enable_cookies(const std::string& cookie_file) { pImpl->cookie_file = cookie_file; +} + +// ==================== 异步请求实现 ==================== + +void HttpClient::start_async_fetch(const std::string& url) { + // 如果有正在进行的请求,先取消 + if (pImpl->async_easy) { + cancel_async(); + } + + // 创建新的easy handle + pImpl->async_easy = curl_easy_init(); + if (!pImpl->async_easy) { + pImpl->async_state = AsyncState::FAILED; + pImpl->async_result.error_message = "Failed to create CURL handle"; + return; + } + + // 配置请求 + pImpl->setup_easy_handle(pImpl->async_easy, url); + + // 设置写回调 + pImpl->async_response_body.clear(); + curl_easy_setopt(pImpl->async_easy, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(pImpl->async_easy, CURLOPT_WRITEDATA, &pImpl->async_response_body); + + // 添加到multi handle + curl_multi_add_handle(pImpl->multi_handle, pImpl->async_easy); + + pImpl->async_state = AsyncState::LOADING; + pImpl->async_result = HttpResponse{}; // 重置结果 +} + +AsyncState HttpClient::poll_async() { + if (pImpl->async_state != AsyncState::LOADING) { + return pImpl->async_state; + } + + // 执行非阻塞的multi perform + int still_running = 0; + CURLMcode mc = curl_multi_perform(pImpl->multi_handle, &still_running); + + if (mc != CURLM_OK) { + pImpl->async_result.error_message = curl_multi_strerror(mc); + pImpl->async_state = AsyncState::FAILED; + pImpl->cleanup_async(); + return pImpl->async_state; + } + + // 检查是否有完成的请求 + int msgs_left = 0; + CURLMsg* msg; + while ((msg = curl_multi_info_read(pImpl->multi_handle, &msgs_left))) { + if (msg->msg == CURLMSG_DONE) { + CURL* easy = msg->easy_handle; + CURLcode result = msg->data.result; + + if (result == CURLE_OK) { + // 获取响应信息 + long http_code = 0; + curl_easy_getinfo(easy, CURLINFO_RESPONSE_CODE, &http_code); + pImpl->async_result.status_code = static_cast(http_code); + + char* content_type = nullptr; + curl_easy_getinfo(easy, CURLINFO_CONTENT_TYPE, &content_type); + if (content_type) { + pImpl->async_result.content_type = content_type; + } + + pImpl->async_result.body = std::move(pImpl->async_response_body); + pImpl->async_state = AsyncState::COMPLETE; + } else { + pImpl->async_result.error_message = curl_easy_strerror(result); + pImpl->async_state = AsyncState::FAILED; + } + + // 清理handle但保留状态供获取结果 + curl_multi_remove_handle(pImpl->multi_handle, pImpl->async_easy); + curl_easy_cleanup(pImpl->async_easy); + pImpl->async_easy = nullptr; + } + } + + return pImpl->async_state; +} + +HttpResponse HttpClient::get_async_result() { + HttpResponse result = std::move(pImpl->async_result); + pImpl->async_result = HttpResponse{}; + pImpl->async_state = AsyncState::IDLE; + return result; +} + +void HttpClient::cancel_async() { + if (pImpl->async_easy) { + pImpl->cleanup_async(); + pImpl->async_state = AsyncState::CANCELLED; + } +} + +bool HttpClient::is_async_active() const { + return pImpl->async_state == AsyncState::LOADING; } \ No newline at end of file diff --git a/src/http_client.h b/src/http_client.h index 842dfff..c374140 100644 --- a/src/http_client.h +++ b/src/http_client.h @@ -5,6 +5,15 @@ #include #include +// 异步请求状态 +enum class AsyncState { + IDLE, // 无活跃请求 + LOADING, // 请求进行中 + COMPLETE, // 请求成功完成 + FAILED, // 请求失败 + CANCELLED // 请求被取消 +}; + struct HttpResponse { int status_code; std::string body; @@ -36,10 +45,20 @@ public: HttpClient(); ~HttpClient(); + // 同步请求接口 HttpResponse fetch(const std::string& url); BinaryResponse fetch_binary(const std::string& url); HttpResponse post(const std::string& url, const std::string& data, const std::string& content_type = "application/x-www-form-urlencoded"); + + // 异步请求接口 + void start_async_fetch(const std::string& url); + AsyncState poll_async(); // 非阻塞轮询,返回当前状态 + HttpResponse get_async_result(); // 获取结果并重置状态 + void cancel_async(); // 取消当前异步请求 + bool is_async_active() const; // 是否有活跃的异步请求 + + // 配置 void set_timeout(long timeout_seconds); void set_user_agent(const std::string& user_agent); void set_follow_redirects(bool follow);