mirror of
https://github.com/m1ngsama/TUT.git
synced 2025-12-25 02:57:08 +00:00
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:
parent
a9c35765c4
commit
6fb70c91d6
2 changed files with 471 additions and 0 deletions
443
src/browser.cpp
Normal file
443
src/browser.cpp
Normal 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
28
src/browser.h
Normal 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;
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue