feat: Implement browser core with TUI interface

Add main browser engine and user interface:
- Page loading with HTTP client integration
- HTML parsing and text rendering pipeline
- History management (back/forward navigation)
- Link selection and following with Tab navigation
- Search functionality with highlighting
- Scrolling with position tracking
- Status bar with mode indicator and progress
- Built-in help page with usage instructions
- Error handling and user feedback
- Support for static HTML websites

The browser provides a complete vim-style terminal
browsing experience optimized for reading text content.
This commit is contained in:
m1ngsama 2025-12-05 14:59:50 +08:00
parent a9c35765c4
commit 6fb70c91d6
2 changed files with 471 additions and 0 deletions

443
src/browser.cpp Normal file
View file

@ -0,0 +1,443 @@
#include "browser.h"
#include <curses.h>
#include <clocale>
#include <algorithm>
#include <sstream>
class Browser::Impl {
public:
HttpClient http_client;
HtmlParser html_parser;
TextRenderer renderer;
InputHandler input_handler;
ParsedDocument current_doc;
std::vector<RenderedLine> rendered_lines;
std::string current_url;
std::vector<std::string> history;
int history_pos = -1;
// 视图状态
int scroll_pos = 0;
int current_link = -1;
std::string status_message;
std::string search_term;
std::vector<int> search_results; // 匹配行号
// 屏幕尺寸
int screen_height = 0;
int screen_width = 0;
void init_screen() {
setlocale(LC_ALL, "");
initscr();
init_color_scheme();
cbreak();
noecho();
keypad(stdscr, TRUE);
curs_set(0);
timeout(0); // non-blocking
getmaxyx(stdscr, screen_height, screen_width);
}
void cleanup_screen() {
endwin();
}
bool load_page(const std::string& url) {
status_message = "Loading " + url + "...";
draw_screen();
refresh();
auto response = http_client.fetch(url);
if (!response.is_success()) {
status_message = "Error: " + (response.error_message.empty() ?
"HTTP " + std::to_string(response.status_code) :
response.error_message);
return false;
}
current_doc = html_parser.parse(response.body, url);
rendered_lines = renderer.render(current_doc, screen_width);
current_url = url;
scroll_pos = 0;
current_link = -1;
search_results.clear();
// 更新历史
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;
status_message = "Loaded: " + (current_doc.title.empty() ? url : current_doc.title);
return true;
}
void draw_status_bar() {
attron(COLOR_PAIR(COLOR_STATUS_BAR));
mvprintw(screen_height - 1, 0, "%s", std::string(screen_width, ' ').c_str());
// 显示模式和缓冲
std::string mode_str;
InputMode mode = input_handler.get_mode();
switch (mode) {
case InputMode::NORMAL:
mode_str = "NORMAL";
break;
case InputMode::COMMAND:
mode_str = input_handler.get_buffer();
break;
case InputMode::SEARCH:
mode_str = input_handler.get_buffer();
break;
default:
mode_str = "???";
break;
}
// 左侧:模式或命令
mvprintw(screen_height - 1, 0, " %s", mode_str.c_str());
// 中间:状态消息
if (!status_message.empty() && mode == InputMode::NORMAL) {
int msg_x = (screen_width - status_message.length()) / 2;
if (msg_x < mode_str.length() + 2) {
msg_x = mode_str.length() + 2;
}
mvprintw(screen_height - 1, msg_x, "%s", status_message.c_str());
}
// 右侧:位置信息
int total_lines = rendered_lines.size();
int visible_lines = screen_height - 2;
int percentage = 0;
if (total_lines > 0) {
if (scroll_pos == 0) {
percentage = 0;
} else if (scroll_pos + visible_lines >= total_lines) {
percentage = 100;
} else {
percentage = (scroll_pos * 100) / total_lines;
}
}
std::string pos_str = std::to_string(scroll_pos + 1) + "/" +
std::to_string(total_lines) + " " +
std::to_string(percentage) + "%";
if (current_link >= 0 && current_link < static_cast<int>(current_doc.links.size())) {
pos_str = "[Link " + std::to_string(current_link) + "] " + pos_str;
}
mvprintw(screen_height - 1, screen_width - pos_str.length() - 1, "%s", pos_str.c_str());
attroff(COLOR_PAIR(COLOR_STATUS_BAR));
}
void draw_screen() {
clear();
int visible_lines = screen_height - 2;
int content_lines = std::min(static_cast<int>(rendered_lines.size()) - scroll_pos, visible_lines);
for (int i = 0; i < content_lines; ++i) {
int line_idx = scroll_pos + i;
const auto& line = rendered_lines[line_idx];
// 高亮当前链接
if (line.is_link && line.link_index == current_link) {
attron(COLOR_PAIR(COLOR_LINK_ACTIVE));
} else {
attron(COLOR_PAIR(line.color_pair));
if (line.is_bold) {
attron(A_BOLD);
}
}
// 搜索高亮
std::string display_text = line.text;
if (!search_term.empty() &&
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) {
// 简单高亮:整行反色(实际应该只高亮匹配部分)
attron(A_REVERSE);
}
mvprintw(i, 0, "%s", display_text.c_str());
if (!search_term.empty() &&
std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) {
attroff(A_REVERSE);
}
if (line.is_link && line.link_index == current_link) {
attroff(COLOR_PAIR(COLOR_LINK_ACTIVE));
} else {
if (line.is_bold) {
attroff(A_BOLD);
}
attroff(COLOR_PAIR(line.color_pair));
}
}
draw_status_bar();
}
void handle_action(const InputResult& result) {
int visible_lines = screen_height - 2;
int max_scroll = std::max(0, static_cast<int>(rendered_lines.size()) - 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 && result.number <= static_cast<int>(rendered_lines.size())) {
scroll_pos = std::min(result.number - 1, max_scroll);
}
break;
case Action::NEXT_LINK:
if (!current_doc.links.empty()) {
current_link = (current_link + 1) % current_doc.links.size();
// 滚动到链接位置
scroll_to_link(current_link);
}
break;
case Action::PREV_LINK:
if (!current_doc.links.empty()) {
current_link = (current_link - 1 + current_doc.links.size()) % current_doc.links.size();
scroll_to_link(current_link);
}
break;
case Action::FOLLOW_LINK:
if (current_link >= 0 && current_link < static_cast<int>(current_doc.links.size())) {
load_page(current_doc.links[current_link].url);
}
break;
case Action::GO_BACK:
if (history_pos > 0) {
history_pos--;
load_page(history[history_pos]);
} else {
status_message = "No previous page";
}
break;
case Action::GO_FORWARD:
if (history_pos < static_cast<int>(history.size()) - 1) {
history_pos++;
load_page(history[history_pos]);
} else {
status_message = "No next page";
}
break;
case Action::OPEN_URL:
if (!result.text.empty()) {
load_page(result.text);
}
break;
case Action::REFRESH:
if (!current_url.empty()) {
load_page(current_url);
}
break;
case Action::SEARCH_FORWARD:
search_term = result.text;
search_results.clear();
for (size_t i = 0; i < rendered_lines.size(); ++i) {
if (rendered_lines[i].text.find(search_term) != std::string::npos) {
search_results.push_back(i);
}
}
if (!search_results.empty()) {
scroll_pos = search_results[0];
status_message = "Found " + std::to_string(search_results.size()) + " matches";
} else {
status_message = "Pattern not found: " + search_term;
}
break;
case Action::SEARCH_NEXT:
if (!search_results.empty()) {
auto it = std::upper_bound(search_results.begin(), search_results.end(), scroll_pos);
if (it != search_results.end()) {
scroll_pos = *it;
} else {
scroll_pos = search_results[0];
status_message = "Search wrapped to top";
}
}
break;
case Action::SEARCH_PREV:
if (!search_results.empty()) {
auto it = std::lower_bound(search_results.begin(), search_results.end(), scroll_pos);
if (it != search_results.begin()) {
scroll_pos = *(--it);
} else {
scroll_pos = search_results.back();
status_message = "Search wrapped to bottom";
}
}
break;
case Action::HELP:
show_help();
break;
default:
break;
}
}
void scroll_to_link(int link_idx) {
// 查找链接在渲染行中的位置
for (size_t i = 0; i < rendered_lines.size(); ++i) {
if (rendered_lines[i].is_link && rendered_lines[i].link_index == link_idx) {
int visible_lines = screen_height - 2;
if (static_cast<int>(i) < scroll_pos || static_cast<int>(i) >= scroll_pos + visible_lines) {
scroll_pos = std::max(0, static_cast<int>(i) - visible_lines / 2);
}
break;
}
}
}
void show_help() {
std::ostringstream help_html;
help_html << "<html><head><title>TUT Browser Help</title></head><body>"
<< "<h1>TUT Browser - Vim-style Terminal Browser</h1>"
<< "<h2>Navigation</h2>"
<< "<p>j/k or ↓/↑: Scroll down/up</p>"
<< "<p>Ctrl-D or Space: Scroll page down</p>"
<< "<p>Ctrl-U or b: Scroll page up</p>"
<< "<p>gg: Go to top</p>"
<< "<p>G: Go to bottom</p>"
<< "<p>[number]G: Go to line number</p>"
<< "<h2>Links</h2>"
<< "<p>Tab: Next link</p>"
<< "<p>Shift-Tab or T: Previous link</p>"
<< "<p>Enter: Follow link</p>"
<< "<p>h: Go back</p>"
<< "<p>l: Go forward</p>"
<< "<h2>Search</h2>"
<< "<p>/: Start search</p>"
<< "<p>n: Next match</p>"
<< "<p>N: Previous match</p>"
<< "<h2>Commands</h2>"
<< "<p>:q or :quit - Quit browser</p>"
<< "<p>:o URL or :open URL - Open URL</p>"
<< "<p>:r or :refresh - Refresh page</p>"
<< "<p>:h or :help - Show this help</p>"
<< "<p>:[number] - Go to line number</p>"
<< "<h2>Other</h2>"
<< "<p>r: Refresh current page</p>"
<< "<p>q: Quit browser</p>"
<< "<p>?: Show help</p>"
<< "<h2>Important Limitations</h2>"
<< "<p><strong>JavaScript/SPA Websites:</strong> This browser cannot execute JavaScript. "
<< "Single Page Applications (SPAs) built with React, Vue, Angular, etc. will not work properly "
<< "as they render content dynamically with JavaScript.</p>"
<< "<p><strong>Works best with:</strong></p>"
<< "<ul>"
<< "<li>Static HTML websites</li>"
<< "<li>Server-side rendered pages</li>"
<< "<li>Documentation sites</li>"
<< "<li>News sites with HTML content</li>"
<< "<li>Blogs with traditional HTML</li>"
<< "</ul>"
<< "<p><strong>Example sites that work well:</strong></p>"
<< "<p>- https://example.com</p>"
<< "<p>- https://en.wikipedia.org</p>"
<< "<p>- Text-based news sites</p>"
<< "<p><strong>For JavaScript-heavy sites:</strong> You may need to find alternative URLs "
<< "that provide the same content in plain HTML format.</p>"
<< "</body></html>";
current_doc = html_parser.parse(help_html.str(), "help://");
rendered_lines = renderer.render(current_doc, screen_width);
scroll_pos = 0;
current_link = -1;
status_message = "Help - Press q to return";
}
};
Browser::Browser() : pImpl(std::make_unique<Impl>()) {
pImpl->input_handler.set_status_callback([this](const std::string& msg) {
pImpl->status_message = msg;
});
}
Browser::~Browser() = default;
void Browser::run(const std::string& initial_url) {
pImpl->init_screen();
if (!initial_url.empty()) {
load_url(initial_url);
} else {
pImpl->show_help();
}
bool running = true;
while (running) {
pImpl->draw_screen();
refresh();
int ch = getch();
if (ch == ERR) {
napms(50); // 50ms sleep
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 Browser::load_url(const std::string& url) {
return pImpl->load_page(url);
}
std::string Browser::get_current_url() const {
return pImpl->current_url;
}

28
src/browser.h Normal file
View file

@ -0,0 +1,28 @@
#pragma once
#include "http_client.h"
#include "html_parser.h"
#include "text_renderer.h"
#include "input_handler.h"
#include <string>
#include <vector>
#include <memory>
class Browser {
public:
Browser();
~Browser();
// 启动浏览器(进入主循环)
void run(const std::string& initial_url = "");
// 加载URL
bool load_url(const std::string& url);
// 获取当前URL
std::string get_current_url() const;
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};