mirror of
https://github.com/m1ngsama/TUT.git
synced 2026-02-08 00:54:05 +00:00
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
This commit is contained in:
parent
584660a518
commit
18859eef47
3 changed files with 376 additions and 7 deletions
|
|
@ -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<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) {
|
||||
|
|
@ -357,33 +533,33 @@ public:
|
|||
|
||||
case Action::FOLLOW_LINK:
|
||||
if (active_link >= 0 && active_link < static_cast<int>(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<int>(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;
|
||||
|
|
|
|||
|
|
@ -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<Impl>()) {}
|
||||
|
|
@ -298,3 +351,105 @@ 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<int>(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;
|
||||
}
|
||||
|
|
@ -5,6 +5,15 @@
|
|||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
// 异步请求状态
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue