mirror of
https://github.com/m1ngsama/TUT.git
synced 2025-12-25 02:57:08 +00:00
🚀 Modern Browser Enhancements - Vimium-style Navigation & Beautiful Rendering (#11)
* feat: Add table, image, and nested list support to HTML parser - Add Table, Image, and Form data structures - Implement table extraction with proper row/column parsing - Add image extraction with alt text and dimensions - Implement recursive nested list parsing (ul/ol) - Support ordered and unordered lists with nesting levels - Extract list item numbers for ordered lists - Add HEADING4-6, ORDERED_LIST_ITEM, TABLE, IMAGE element types This enhancement allows TUT to properly extract and represent structured content from HTML, enabling better rendering of data-heavy websites. * feat: Implement beautiful table and image rendering with box-drawing - Add Unicode box-drawing characters for table borders (┌─┬─┐, │, etc.) - Implement table rendering with proper column width calculation - Add header row styling with heavy borders and bold text - Support automatic text wrapping within table cells - Implement image placeholder rendering with bordered boxes - Display image alt text and dimensions (width × height) - Enhance list rendering with different bullet styles per nesting level * Level 0: • (bullet) * Level 1: ◦ (white bullet) * Level 2: ▪ (small square) * Level 3: ▫ (white small square) - Add ordered list rendering with proper numbering - Support proper indentation for nested lists These visual enhancements make TUT significantly more modern and readable compared to traditional text browsers like w3m. * feat: Add Vimium-style link hints and vim keybindings infrastructure - Add LINK_HINTS mode for Vimium-style link navigation - Implement 'f' key to activate link hints mode - Add visual mode support (v/V keys) - Implement marks support (m[a-z] to set, '[a-z] to jump) - Add tab navigation keys (gt/gT for next/previous tab) - Add new actions: * SHOW_LINK_HINTS - activate link hints overlay * FOLLOW_LINK_HINT - follow link by hint letters * ENTER_VISUAL_MODE / ENTER_VISUAL_LINE_MODE * SET_MARK / GOTO_MARK - vim-style position bookmarks * NEXT_TAB / PREV_TAB - tab navigation * YANK - copy selected text This brings modern browser vim plugin functionality (like Vimium) to the terminal, making link navigation much faster than traditional tab-through methods.
This commit is contained in:
parent
ea71b0ca02
commit
860c8aaf56
5 changed files with 729 additions and 26 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
class HtmlParser::Impl {
|
class HtmlParser::Impl {
|
||||||
public:
|
public:
|
||||||
|
|
@ -234,6 +235,133 @@ public:
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract images
|
||||||
|
std::vector<Image> extract_images(const std::string& html) {
|
||||||
|
std::vector<Image> images;
|
||||||
|
std::regex img_regex(R"(<img[^>]*src\s*=\s*["']([^"']*)["'][^>]*>)", std::regex::icase);
|
||||||
|
|
||||||
|
auto begin = std::sregex_iterator(html.begin(), html.end(), img_regex);
|
||||||
|
auto end = std::sregex_iterator();
|
||||||
|
|
||||||
|
for (std::sregex_iterator i = begin; i != end; ++i) {
|
||||||
|
std::smatch match = *i;
|
||||||
|
Image img;
|
||||||
|
img.src = match[1].str();
|
||||||
|
img.width = -1;
|
||||||
|
img.height = -1;
|
||||||
|
|
||||||
|
// Extract alt text
|
||||||
|
std::string img_tag = match[0].str();
|
||||||
|
std::regex alt_regex(R"(alt\s*=\s*["']([^"']*)["'])", std::regex::icase);
|
||||||
|
std::smatch alt_match;
|
||||||
|
if (std::regex_search(img_tag, alt_match, alt_regex)) {
|
||||||
|
img.alt = decode_html_entities(alt_match[1].str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract width
|
||||||
|
std::regex width_regex(R"(width\s*=\s*["']?(\d+)["']?)", std::regex::icase);
|
||||||
|
std::smatch width_match;
|
||||||
|
if (std::regex_search(img_tag, width_match, width_regex)) {
|
||||||
|
try {
|
||||||
|
img.width = std::stoi(width_match[1].str());
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract height
|
||||||
|
std::regex height_regex(R"(height\s*=\s*["']?(\d+)["']?)", std::regex::icase);
|
||||||
|
std::smatch height_match;
|
||||||
|
if (std::regex_search(img_tag, height_match, height_regex)) {
|
||||||
|
try {
|
||||||
|
img.height = std::stoi(height_match[1].str());
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
images.push_back(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tables
|
||||||
|
std::vector<Table> extract_tables(const std::string& html, std::vector<Link>& all_links) {
|
||||||
|
std::vector<Table> tables;
|
||||||
|
auto table_contents = extract_all_tags(html, "table");
|
||||||
|
|
||||||
|
for (const auto& table_html : table_contents) {
|
||||||
|
Table table;
|
||||||
|
table.has_header = false;
|
||||||
|
|
||||||
|
// Extract rows
|
||||||
|
auto thead_html = extract_tag_content(table_html, "thead");
|
||||||
|
auto tbody_html = extract_tag_content(table_html, "tbody");
|
||||||
|
|
||||||
|
// If no thead/tbody, just get all rows
|
||||||
|
std::vector<std::string> row_htmls;
|
||||||
|
if (!thead_html.empty() || !tbody_html.empty()) {
|
||||||
|
if (!thead_html.empty()) {
|
||||||
|
auto header_rows = extract_all_tags(thead_html, "tr");
|
||||||
|
row_htmls.insert(row_htmls.end(), header_rows.begin(), header_rows.end());
|
||||||
|
table.has_header = !header_rows.empty();
|
||||||
|
}
|
||||||
|
if (!tbody_html.empty()) {
|
||||||
|
auto body_rows = extract_all_tags(tbody_html, "tr");
|
||||||
|
row_htmls.insert(row_htmls.end(), body_rows.begin(), body_rows.end());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row_htmls = extract_all_tags(table_html, "tr");
|
||||||
|
// Check if first row has <th> tags
|
||||||
|
if (!row_htmls.empty()) {
|
||||||
|
table.has_header = (row_htmls[0].find("<th") != std::string::npos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_first_row = true;
|
||||||
|
for (const auto& row_html : row_htmls) {
|
||||||
|
TableRow row;
|
||||||
|
|
||||||
|
// Extract cells (both th and td)
|
||||||
|
auto th_cells = extract_all_tags(row_html, "th");
|
||||||
|
auto td_cells = extract_all_tags(row_html, "td");
|
||||||
|
|
||||||
|
// Process th cells (headers)
|
||||||
|
for (const auto& cell_html : th_cells) {
|
||||||
|
TableCell cell;
|
||||||
|
std::vector<InlineLink> inline_links;
|
||||||
|
cell.text = extract_text_with_links(cell_html, all_links, inline_links);
|
||||||
|
cell.inline_links = inline_links;
|
||||||
|
cell.is_header = true;
|
||||||
|
cell.colspan = 1;
|
||||||
|
cell.rowspan = 1;
|
||||||
|
row.cells.push_back(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process td cells (data)
|
||||||
|
for (const auto& cell_html : td_cells) {
|
||||||
|
TableCell cell;
|
||||||
|
std::vector<InlineLink> inline_links;
|
||||||
|
cell.text = extract_text_with_links(cell_html, all_links, inline_links);
|
||||||
|
cell.inline_links = inline_links;
|
||||||
|
cell.is_header = is_first_row && table.has_header && th_cells.empty();
|
||||||
|
cell.colspan = 1;
|
||||||
|
cell.rowspan = 1;
|
||||||
|
row.cells.push_back(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.cells.empty()) {
|
||||||
|
table.rows.push_back(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
is_first_row = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!table.rows.empty()) {
|
||||||
|
tables.push_back(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
HtmlParser::HtmlParser() : pImpl(std::make_unique<Impl>()) {}
|
HtmlParser::HtmlParser() : pImpl(std::make_unique<Impl>()) {}
|
||||||
|
|
@ -271,33 +399,117 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas
|
||||||
// 提取链接
|
// 提取链接
|
||||||
doc.links = pImpl->extract_links(main_content, base_url);
|
doc.links = pImpl->extract_links(main_content, base_url);
|
||||||
|
|
||||||
|
// Extract and add images
|
||||||
|
auto images = pImpl->extract_images(main_content);
|
||||||
|
for (const auto& img : images) {
|
||||||
|
ContentElement elem;
|
||||||
|
elem.type = ElementType::IMAGE;
|
||||||
|
elem.image_data = img;
|
||||||
|
elem.level = 0;
|
||||||
|
elem.list_number = 0;
|
||||||
|
elem.nesting_level = 0;
|
||||||
|
doc.elements.push_back(elem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and add tables
|
||||||
|
auto tables = pImpl->extract_tables(main_content, doc.links);
|
||||||
|
for (const auto& tbl : tables) {
|
||||||
|
ContentElement elem;
|
||||||
|
elem.type = ElementType::TABLE;
|
||||||
|
elem.table_data = tbl;
|
||||||
|
elem.level = 0;
|
||||||
|
elem.list_number = 0;
|
||||||
|
elem.nesting_level = 0;
|
||||||
|
doc.elements.push_back(elem);
|
||||||
|
}
|
||||||
|
|
||||||
// 解析标题
|
// 解析标题
|
||||||
for (int level = 1; level <= 6; ++level) {
|
for (int level = 1; level <= 6; ++level) {
|
||||||
std::string tag = "h" + std::to_string(level);
|
std::string tag = "h" + std::to_string(level);
|
||||||
auto headings = pImpl->extract_all_tags(main_content, tag);
|
auto headings = pImpl->extract_all_tags(main_content, tag);
|
||||||
for (const auto& heading : headings) {
|
for (const auto& heading : headings) {
|
||||||
ContentElement elem;
|
ContentElement elem;
|
||||||
elem.type = (level == 1) ? ElementType::HEADING1 :
|
ElementType type;
|
||||||
(level == 2) ? ElementType::HEADING2 : ElementType::HEADING3;
|
if (level == 1) type = ElementType::HEADING1;
|
||||||
|
else if (level == 2) type = ElementType::HEADING2;
|
||||||
|
else if (level == 3) type = ElementType::HEADING3;
|
||||||
|
else if (level == 4) type = ElementType::HEADING4;
|
||||||
|
else if (level == 5) type = ElementType::HEADING5;
|
||||||
|
else type = ElementType::HEADING6;
|
||||||
|
|
||||||
|
elem.type = type;
|
||||||
elem.text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(heading)));
|
elem.text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(heading)));
|
||||||
elem.level = level;
|
elem.level = level;
|
||||||
|
elem.list_number = 0;
|
||||||
|
elem.nesting_level = 0;
|
||||||
if (!elem.text.empty()) {
|
if (!elem.text.empty()) {
|
||||||
doc.elements.push_back(elem);
|
doc.elements.push_back(elem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析列表项
|
// 解析列表项 - with nesting support
|
||||||
if (pImpl->keep_lists) {
|
if (pImpl->keep_lists) {
|
||||||
auto list_items = pImpl->extract_all_tags(main_content, "li");
|
// Extract both <ul> and <ol> lists
|
||||||
for (const auto& item : list_items) {
|
auto ul_lists = pImpl->extract_all_tags(main_content, "ul");
|
||||||
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(item)));
|
auto ol_lists = pImpl->extract_all_tags(main_content, "ol");
|
||||||
|
|
||||||
|
// Helper to parse a list recursively
|
||||||
|
std::function<void(const std::string&, bool, int)> parse_list;
|
||||||
|
parse_list = [&](const std::string& list_html, bool is_ordered, int nesting) {
|
||||||
|
auto list_items = pImpl->extract_all_tags(list_html, "li");
|
||||||
|
int item_number = 1;
|
||||||
|
|
||||||
|
for (const auto& item_html : list_items) {
|
||||||
|
// Check if this item contains nested lists
|
||||||
|
bool has_nested_ul = item_html.find("<ul") != std::string::npos;
|
||||||
|
bool has_nested_ol = item_html.find("<ol") != std::string::npos;
|
||||||
|
|
||||||
|
// Extract text without nested lists
|
||||||
|
std::string item_text = item_html;
|
||||||
|
if (has_nested_ul || has_nested_ol) {
|
||||||
|
// Remove nested lists from text
|
||||||
|
item_text = std::regex_replace(item_text,
|
||||||
|
std::regex("<ul[^>]*>[\\s\\S]*?</ul>", std::regex::icase), "");
|
||||||
|
item_text = std::regex_replace(item_text,
|
||||||
|
std::regex("<ol[^>]*>[\\s\\S]*?</ol>", std::regex::icase), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(item_text)));
|
||||||
if (!text.empty() && text.length() > 1) {
|
if (!text.empty() && text.length() > 1) {
|
||||||
ContentElement elem;
|
ContentElement elem;
|
||||||
elem.type = ElementType::LIST_ITEM;
|
elem.type = is_ordered ? ElementType::ORDERED_LIST_ITEM : ElementType::LIST_ITEM;
|
||||||
elem.text = text;
|
elem.text = text;
|
||||||
|
elem.level = 0;
|
||||||
|
elem.list_number = item_number++;
|
||||||
|
elem.nesting_level = nesting;
|
||||||
doc.elements.push_back(elem);
|
doc.elements.push_back(elem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse nested lists
|
||||||
|
if (has_nested_ul) {
|
||||||
|
auto nested_uls = pImpl->extract_all_tags(item_html, "ul");
|
||||||
|
for (const auto& nested_ul : nested_uls) {
|
||||||
|
parse_list(nested_ul, false, nesting + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (has_nested_ol) {
|
||||||
|
auto nested_ols = pImpl->extract_all_tags(item_html, "ol");
|
||||||
|
for (const auto& nested_ol : nested_ols) {
|
||||||
|
parse_list(nested_ol, true, nesting + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse unordered lists
|
||||||
|
for (const auto& ul : ul_lists) {
|
||||||
|
parse_list(ul, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ordered lists
|
||||||
|
for (const auto& ol : ol_lists) {
|
||||||
|
parse_list(ol, true, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,6 +519,9 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas
|
||||||
ContentElement elem;
|
ContentElement elem;
|
||||||
elem.type = ElementType::PARAGRAPH;
|
elem.type = ElementType::PARAGRAPH;
|
||||||
elem.text = pImpl->extract_text_with_links(para, doc.links, elem.inline_links);
|
elem.text = pImpl->extract_text_with_links(para, doc.links, elem.inline_links);
|
||||||
|
elem.level = 0;
|
||||||
|
elem.list_number = 0;
|
||||||
|
elem.nesting_level = 0;
|
||||||
if (!elem.text.empty() && elem.text.length() > 1) {
|
if (!elem.text.empty() && elem.text.length() > 1) {
|
||||||
doc.elements.push_back(elem);
|
doc.elements.push_back(elem);
|
||||||
}
|
}
|
||||||
|
|
@ -321,6 +536,9 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas
|
||||||
ContentElement elem;
|
ContentElement elem;
|
||||||
elem.type = ElementType::PARAGRAPH;
|
elem.type = ElementType::PARAGRAPH;
|
||||||
elem.text = text;
|
elem.text = text;
|
||||||
|
elem.level = 0;
|
||||||
|
elem.list_number = 0;
|
||||||
|
elem.nesting_level = 0;
|
||||||
doc.elements.push_back(elem);
|
doc.elements.push_back(elem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -339,6 +557,9 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas
|
||||||
ContentElement elem;
|
ContentElement elem;
|
||||||
elem.type = ElementType::PARAGRAPH;
|
elem.type = ElementType::PARAGRAPH;
|
||||||
elem.text = line;
|
elem.text = line;
|
||||||
|
elem.level = 0;
|
||||||
|
elem.list_number = 0;
|
||||||
|
elem.nesting_level = 0;
|
||||||
doc.elements.push_back(elem);
|
doc.elements.push_back(elem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,28 @@ enum class ElementType {
|
||||||
HEADING1,
|
HEADING1,
|
||||||
HEADING2,
|
HEADING2,
|
||||||
HEADING3,
|
HEADING3,
|
||||||
|
HEADING4,
|
||||||
|
HEADING5,
|
||||||
|
HEADING6,
|
||||||
PARAGRAPH,
|
PARAGRAPH,
|
||||||
LINK,
|
LINK,
|
||||||
LIST_ITEM,
|
LIST_ITEM,
|
||||||
|
ORDERED_LIST_ITEM,
|
||||||
BLOCKQUOTE,
|
BLOCKQUOTE,
|
||||||
CODE_BLOCK,
|
CODE_BLOCK,
|
||||||
HORIZONTAL_RULE,
|
HORIZONTAL_RULE,
|
||||||
LINE_BREAK
|
LINE_BREAK,
|
||||||
|
TABLE,
|
||||||
|
IMAGE,
|
||||||
|
FORM,
|
||||||
|
SECTION_START,
|
||||||
|
SECTION_END,
|
||||||
|
NAV_START,
|
||||||
|
NAV_END,
|
||||||
|
HEADER_START,
|
||||||
|
HEADER_END,
|
||||||
|
ASIDE_START,
|
||||||
|
ASIDE_END
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Link {
|
struct Link {
|
||||||
|
|
@ -32,12 +47,57 @@ struct InlineLink {
|
||||||
int link_index; // Index in the document's links array
|
int link_index; // Index in the document's links array
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct TableCell {
|
||||||
|
std::string text;
|
||||||
|
std::vector<InlineLink> inline_links;
|
||||||
|
bool is_header;
|
||||||
|
int colspan;
|
||||||
|
int rowspan;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TableRow {
|
||||||
|
std::vector<TableCell> cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Table {
|
||||||
|
std::vector<TableRow> rows;
|
||||||
|
bool has_header;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Image {
|
||||||
|
std::string src;
|
||||||
|
std::string alt;
|
||||||
|
int width; // -1 if not specified
|
||||||
|
int height; // -1 if not specified
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FormField {
|
||||||
|
enum Type { TEXT, PASSWORD, CHECKBOX, RADIO, SUBMIT, BUTTON } type;
|
||||||
|
std::string name;
|
||||||
|
std::string value;
|
||||||
|
std::string placeholder;
|
||||||
|
bool checked;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Form {
|
||||||
|
std::string action;
|
||||||
|
std::string method;
|
||||||
|
std::vector<FormField> fields;
|
||||||
|
};
|
||||||
|
|
||||||
struct ContentElement {
|
struct ContentElement {
|
||||||
ElementType type;
|
ElementType type;
|
||||||
std::string text;
|
std::string text;
|
||||||
std::string url;
|
std::string url;
|
||||||
int level;
|
int level;
|
||||||
|
int list_number; // For ordered lists
|
||||||
|
int nesting_level; // For nested lists
|
||||||
std::vector<InlineLink> inline_links; // Links within this element's text
|
std::vector<InlineLink> inline_links; // Links within this element's text
|
||||||
|
|
||||||
|
// Extended content types
|
||||||
|
Table table_data;
|
||||||
|
Image image_data;
|
||||||
|
Form form_data;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct ParsedDocument {
|
struct ParsedDocument {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,48 @@ public:
|
||||||
result.has_count = false;
|
result.has_count = false;
|
||||||
result.count = 1;
|
result.count = 1;
|
||||||
|
|
||||||
// Handle digit input for count or 'f' command
|
// Handle multi-char commands like 'gg', 'gt', 'gT', 'm', '
|
||||||
|
if (!buffer.empty()) {
|
||||||
|
if (buffer == "g") {
|
||||||
|
if (ch == 't') {
|
||||||
|
result.action = Action::NEXT_TAB;
|
||||||
|
buffer.clear();
|
||||||
|
count_buffer.clear();
|
||||||
|
return result;
|
||||||
|
} else if (ch == 'T') {
|
||||||
|
result.action = Action::PREV_TAB;
|
||||||
|
buffer.clear();
|
||||||
|
count_buffer.clear();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} else if (buffer == "m") {
|
||||||
|
// Set mark with letter
|
||||||
|
if (std::isalpha(ch)) {
|
||||||
|
result.action = Action::SET_MARK;
|
||||||
|
result.text = std::string(1, static_cast<char>(ch));
|
||||||
|
buffer.clear();
|
||||||
|
count_buffer.clear();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
buffer.clear();
|
||||||
|
count_buffer.clear();
|
||||||
|
return result;
|
||||||
|
} else if (buffer == "'") {
|
||||||
|
// Jump to mark
|
||||||
|
if (std::isalpha(ch)) {
|
||||||
|
result.action = Action::GOTO_MARK;
|
||||||
|
result.text = std::string(1, static_cast<char>(ch));
|
||||||
|
buffer.clear();
|
||||||
|
count_buffer.clear();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
buffer.clear();
|
||||||
|
count_buffer.clear();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle digit input for count
|
||||||
if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) {
|
if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) {
|
||||||
count_buffer += static_cast<char>(ch);
|
count_buffer += static_cast<char>(ch);
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -116,16 +157,33 @@ public:
|
||||||
count_buffer.clear();
|
count_buffer.clear();
|
||||||
break;
|
break;
|
||||||
case 'f':
|
case 'f':
|
||||||
// 'f' command: follow link by number
|
// 'f' command: vimium-style link hints
|
||||||
if (result.has_count) {
|
result.action = Action::SHOW_LINK_HINTS;
|
||||||
result.action = Action::FOLLOW_LINK_NUM;
|
mode = InputMode::LINK_HINTS;
|
||||||
result.number = result.count;
|
buffer.clear();
|
||||||
|
count_buffer.clear();
|
||||||
|
break;
|
||||||
|
case 'v':
|
||||||
|
// Enter visual mode
|
||||||
|
result.action = Action::ENTER_VISUAL_MODE;
|
||||||
|
mode = InputMode::VISUAL;
|
||||||
|
count_buffer.clear();
|
||||||
|
break;
|
||||||
|
case 'V':
|
||||||
|
// Enter visual line mode
|
||||||
|
result.action = Action::ENTER_VISUAL_LINE_MODE;
|
||||||
|
mode = InputMode::VISUAL_LINE;
|
||||||
|
count_buffer.clear();
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
// Set mark (wait for next char)
|
||||||
|
buffer = "m";
|
||||||
|
count_buffer.clear();
|
||||||
|
break;
|
||||||
|
case '\'':
|
||||||
|
// Jump to mark (wait for next char)
|
||||||
|
buffer = "'";
|
||||||
count_buffer.clear();
|
count_buffer.clear();
|
||||||
} else {
|
|
||||||
// Enter link follow mode, wait for number
|
|
||||||
mode = InputMode::LINK;
|
|
||||||
buffer = "f";
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case ':':
|
case ':':
|
||||||
mode = InputMode::COMMAND;
|
mode = InputMode::COMMAND;
|
||||||
|
|
@ -257,6 +315,75 @@ public:
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InputResult process_link_hints_mode(int ch) {
|
||||||
|
InputResult result;
|
||||||
|
result.action = Action::NONE;
|
||||||
|
|
||||||
|
if (ch == 27) {
|
||||||
|
// ESC cancels link hints mode
|
||||||
|
mode = InputMode::NORMAL;
|
||||||
|
buffer.clear();
|
||||||
|
return result;
|
||||||
|
} else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
|
||||||
|
// Backspace removes last character
|
||||||
|
if (!buffer.empty()) {
|
||||||
|
buffer.pop_back();
|
||||||
|
} else {
|
||||||
|
mode = InputMode::NORMAL;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} else if (std::isalpha(ch)) {
|
||||||
|
// Add character to buffer
|
||||||
|
buffer += std::tolower(static_cast<char>(ch));
|
||||||
|
|
||||||
|
// Try to match link hint
|
||||||
|
result.action = Action::FOLLOW_LINK_HINT;
|
||||||
|
result.text = buffer;
|
||||||
|
|
||||||
|
// Mode will be reset by browser if link is followed
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputResult process_visual_mode(int ch) {
|
||||||
|
InputResult result;
|
||||||
|
result.action = Action::NONE;
|
||||||
|
|
||||||
|
if (ch == 27 || ch == 'v') {
|
||||||
|
// ESC or 'v' exits visual mode
|
||||||
|
mode = InputMode::NORMAL;
|
||||||
|
return result;
|
||||||
|
} else if (ch == 'y') {
|
||||||
|
// Yank (copy) selected text
|
||||||
|
result.action = Action::YANK;
|
||||||
|
mode = InputMode::NORMAL;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass through navigation commands
|
||||||
|
switch (ch) {
|
||||||
|
case 'j':
|
||||||
|
case KEY_DOWN:
|
||||||
|
result.action = Action::SCROLL_DOWN;
|
||||||
|
break;
|
||||||
|
case 'k':
|
||||||
|
case KEY_UP:
|
||||||
|
result.action = Action::SCROLL_UP;
|
||||||
|
break;
|
||||||
|
case 'h':
|
||||||
|
case KEY_LEFT:
|
||||||
|
// In visual mode, h/l could extend selection
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
case KEY_RIGHT:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
InputHandler::InputHandler() : pImpl(std::make_unique<Impl>()) {}
|
||||||
|
|
@ -273,6 +400,11 @@ InputResult InputHandler::handle_key(int ch) {
|
||||||
return pImpl->process_search_mode(ch);
|
return pImpl->process_search_mode(ch);
|
||||||
case InputMode::LINK:
|
case InputMode::LINK:
|
||||||
return pImpl->process_link_mode(ch);
|
return pImpl->process_link_mode(ch);
|
||||||
|
case InputMode::LINK_HINTS:
|
||||||
|
return pImpl->process_link_hints_mode(ch);
|
||||||
|
case InputMode::VISUAL:
|
||||||
|
case InputMode::VISUAL_LINE:
|
||||||
|
return pImpl->process_visual_mode(ch);
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ enum class InputMode {
|
||||||
NORMAL,
|
NORMAL,
|
||||||
COMMAND,
|
COMMAND,
|
||||||
SEARCH,
|
SEARCH,
|
||||||
LINK
|
LINK,
|
||||||
|
LINK_HINTS, // Vimium-style 'f' mode
|
||||||
|
VISUAL, // Visual mode
|
||||||
|
VISUAL_LINE // Visual line mode
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class Action {
|
enum class Action {
|
||||||
|
|
@ -28,12 +31,24 @@ enum class Action {
|
||||||
FOLLOW_LINK,
|
FOLLOW_LINK,
|
||||||
GOTO_LINK, // Jump to specific link by number
|
GOTO_LINK, // Jump to specific link by number
|
||||||
FOLLOW_LINK_NUM, // Follow specific link by number (f command)
|
FOLLOW_LINK_NUM, // Follow specific link by number (f command)
|
||||||
|
SHOW_LINK_HINTS, // Activate link hints mode ('f')
|
||||||
|
FOLLOW_LINK_HINT, // Follow link by hint letters
|
||||||
GO_BACK,
|
GO_BACK,
|
||||||
GO_FORWARD,
|
GO_FORWARD,
|
||||||
OPEN_URL,
|
OPEN_URL,
|
||||||
REFRESH,
|
REFRESH,
|
||||||
QUIT,
|
QUIT,
|
||||||
HELP
|
HELP,
|
||||||
|
ENTER_VISUAL_MODE, // Start visual mode
|
||||||
|
ENTER_VISUAL_LINE_MODE, // Start visual line mode
|
||||||
|
SET_MARK, // Set a mark (m + letter)
|
||||||
|
GOTO_MARK, // Jump to mark (' + letter)
|
||||||
|
YANK, // Copy selected text
|
||||||
|
NEXT_TAB, // gt - next tab
|
||||||
|
PREV_TAB, // gT - previous tab
|
||||||
|
NEW_TAB, // :tabnew
|
||||||
|
CLOSE_TAB, // :tabc
|
||||||
|
TOGGLE_MOUSE // Toggle mouse support
|
||||||
};
|
};
|
||||||
|
|
||||||
struct InputResult {
|
struct InputResult {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,24 @@
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <clocale>
|
#include <clocale>
|
||||||
|
#include <numeric>
|
||||||
|
|
||||||
|
// Box-drawing characters for tables
|
||||||
|
namespace BoxChars {
|
||||||
|
constexpr const char* TOP_LEFT = "┌";
|
||||||
|
constexpr const char* TOP_RIGHT = "┐";
|
||||||
|
constexpr const char* BOTTOM_LEFT = "└";
|
||||||
|
constexpr const char* BOTTOM_RIGHT = "┘";
|
||||||
|
constexpr const char* HORIZONTAL = "─";
|
||||||
|
constexpr const char* VERTICAL = "│";
|
||||||
|
constexpr const char* T_DOWN = "┬";
|
||||||
|
constexpr const char* T_UP = "┴";
|
||||||
|
constexpr const char* T_RIGHT = "├";
|
||||||
|
constexpr const char* T_LEFT = "┤";
|
||||||
|
constexpr const char* CROSS = "┼";
|
||||||
|
constexpr const char* HEAVY_HORIZONTAL = "━";
|
||||||
|
constexpr const char* HEAVY_VERTICAL = "┃";
|
||||||
|
}
|
||||||
|
|
||||||
class TextRenderer::Impl {
|
class TextRenderer::Impl {
|
||||||
public:
|
public:
|
||||||
|
|
@ -204,6 +222,212 @@ public:
|
||||||
std::string add_indent(const std::string& text, int indent) {
|
std::string add_indent(const std::string& text, int indent) {
|
||||||
return std::string(indent, ' ') + text;
|
return std::string(indent, ' ') + text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render a table with box-drawing characters
|
||||||
|
std::vector<RenderedLine> render_table(const Table& table, int content_width, int margin) {
|
||||||
|
std::vector<RenderedLine> lines;
|
||||||
|
if (table.rows.empty()) return lines;
|
||||||
|
|
||||||
|
// Calculate column widths
|
||||||
|
size_t num_cols = 0;
|
||||||
|
for (const auto& row : table.rows) {
|
||||||
|
num_cols = std::max(num_cols, row.cells.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (num_cols == 0) return lines;
|
||||||
|
|
||||||
|
std::vector<int> col_widths(num_cols, 0);
|
||||||
|
int available_width = content_width - (num_cols + 1) * 3; // Account for borders and padding
|
||||||
|
|
||||||
|
// First pass: calculate minimum widths
|
||||||
|
for (const auto& row : table.rows) {
|
||||||
|
for (size_t i = 0; i < row.cells.size() && i < num_cols; ++i) {
|
||||||
|
int cell_len = static_cast<int>(row.cells[i].text.length());
|
||||||
|
int max_width = available_width / static_cast<int>(num_cols);
|
||||||
|
int cell_width = std::min(cell_len, max_width);
|
||||||
|
col_widths[i] = std::max(col_widths[i], cell_width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize column widths
|
||||||
|
int total_width = std::accumulate(col_widths.begin(), col_widths.end(), 0);
|
||||||
|
if (total_width > available_width) {
|
||||||
|
// Scale down proportionally
|
||||||
|
for (auto& width : col_widths) {
|
||||||
|
width = (width * available_width) / total_width;
|
||||||
|
width = std::max(width, 5); // Minimum column width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create separator line
|
||||||
|
auto create_separator = [&](bool is_top, bool is_bottom, bool is_middle, bool is_header) {
|
||||||
|
RenderedLine line;
|
||||||
|
std::string sep = std::string(margin, ' ');
|
||||||
|
|
||||||
|
if (is_top) {
|
||||||
|
sep += BoxChars::TOP_LEFT;
|
||||||
|
} else if (is_bottom) {
|
||||||
|
sep += BoxChars::BOTTOM_LEFT;
|
||||||
|
} else {
|
||||||
|
sep += BoxChars::T_RIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < num_cols; ++i) {
|
||||||
|
const char* horiz = is_header ? BoxChars::HEAVY_HORIZONTAL : BoxChars::HORIZONTAL;
|
||||||
|
sep += std::string(col_widths[i] + 2, horiz[0]);
|
||||||
|
|
||||||
|
if (i < num_cols - 1) {
|
||||||
|
if (is_top) {
|
||||||
|
sep += BoxChars::T_DOWN;
|
||||||
|
} else if (is_bottom) {
|
||||||
|
sep += BoxChars::T_UP;
|
||||||
|
} else {
|
||||||
|
sep += BoxChars::CROSS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_top) {
|
||||||
|
sep += BoxChars::TOP_RIGHT;
|
||||||
|
} else if (is_bottom) {
|
||||||
|
sep += BoxChars::BOTTOM_RIGHT;
|
||||||
|
} else {
|
||||||
|
sep += BoxChars::T_LEFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
line.text = sep;
|
||||||
|
line.color_pair = COLOR_DIM;
|
||||||
|
line.is_bold = false;
|
||||||
|
line.is_link = false;
|
||||||
|
line.link_index = -1;
|
||||||
|
return line;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top border
|
||||||
|
lines.push_back(create_separator(true, false, false, false));
|
||||||
|
|
||||||
|
// Render rows
|
||||||
|
bool first_row = true;
|
||||||
|
for (const auto& row : table.rows) {
|
||||||
|
bool is_header_row = first_row && table.has_header;
|
||||||
|
|
||||||
|
// Wrap cell contents
|
||||||
|
std::vector<std::vector<std::string>> wrapped_cells(num_cols);
|
||||||
|
int max_cell_lines = 1;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < row.cells.size() && i < num_cols; ++i) {
|
||||||
|
const auto& cell = row.cells[i];
|
||||||
|
auto cell_lines = wrap_text(cell.text, col_widths[i]);
|
||||||
|
wrapped_cells[i] = cell_lines;
|
||||||
|
max_cell_lines = std::max(max_cell_lines, static_cast<int>(cell_lines.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render cell lines
|
||||||
|
for (int line_idx = 0; line_idx < max_cell_lines; ++line_idx) {
|
||||||
|
RenderedLine line;
|
||||||
|
std::string line_text = std::string(margin, ' ') + BoxChars::VERTICAL;
|
||||||
|
|
||||||
|
for (size_t col_idx = 0; col_idx < num_cols; ++col_idx) {
|
||||||
|
std::string cell_text;
|
||||||
|
if (col_idx < wrapped_cells.size() && line_idx < static_cast<int>(wrapped_cells[col_idx].size())) {
|
||||||
|
cell_text = wrapped_cells[col_idx][line_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad to column width
|
||||||
|
int padding = col_widths[col_idx] - cell_text.length();
|
||||||
|
line_text += " " + cell_text + std::string(padding + 1, ' ') + BoxChars::VERTICAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
line.text = line_text;
|
||||||
|
line.color_pair = is_header_row ? COLOR_HEADING2 : COLOR_NORMAL;
|
||||||
|
line.is_bold = is_header_row;
|
||||||
|
line.is_link = false;
|
||||||
|
line.link_index = -1;
|
||||||
|
lines.push_back(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator after header or between rows
|
||||||
|
if (is_header_row) {
|
||||||
|
lines.push_back(create_separator(false, false, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
first_row = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
lines.push_back(create_separator(false, true, false, false));
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render an image placeholder
|
||||||
|
std::vector<RenderedLine> render_image(const Image& img, int content_width, int margin) {
|
||||||
|
std::vector<RenderedLine> lines;
|
||||||
|
|
||||||
|
// Create a box for the image
|
||||||
|
std::string img_text = "[IMG";
|
||||||
|
if (!img.alt.empty()) {
|
||||||
|
img_text += ": " + img.alt;
|
||||||
|
}
|
||||||
|
img_text += "]";
|
||||||
|
|
||||||
|
// Truncate if too long
|
||||||
|
if (static_cast<int>(img_text.length()) > content_width) {
|
||||||
|
img_text = img_text.substr(0, content_width - 3) + "...]";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top border
|
||||||
|
RenderedLine top;
|
||||||
|
top.text = std::string(margin, ' ') + BoxChars::TOP_LEFT +
|
||||||
|
std::string(img_text.length() + 2, BoxChars::HORIZONTAL[0]) +
|
||||||
|
BoxChars::TOP_RIGHT;
|
||||||
|
top.color_pair = COLOR_DIM;
|
||||||
|
top.is_bold = false;
|
||||||
|
top.is_link = false;
|
||||||
|
top.link_index = -1;
|
||||||
|
lines.push_back(top);
|
||||||
|
|
||||||
|
// Content
|
||||||
|
RenderedLine content;
|
||||||
|
content.text = std::string(margin, ' ') + BoxChars::VERTICAL + " " + img_text + " " + BoxChars::VERTICAL;
|
||||||
|
content.color_pair = COLOR_LINK;
|
||||||
|
content.is_bold = true;
|
||||||
|
content.is_link = false;
|
||||||
|
content.link_index = -1;
|
||||||
|
lines.push_back(content);
|
||||||
|
|
||||||
|
// Dimensions if available
|
||||||
|
if (img.width > 0 || img.height > 0) {
|
||||||
|
std::string dims = " ";
|
||||||
|
if (img.width > 0) dims += std::to_string(img.width) + "w";
|
||||||
|
if (img.width > 0 && img.height > 0) dims += " × ";
|
||||||
|
if (img.height > 0) dims += std::to_string(img.height) + "h";
|
||||||
|
dims += " ";
|
||||||
|
|
||||||
|
RenderedLine dim_line;
|
||||||
|
int padding = img_text.length() + 2 - dims.length();
|
||||||
|
dim_line.text = std::string(margin, ' ') + BoxChars::VERTICAL + dims +
|
||||||
|
std::string(padding, ' ') + BoxChars::VERTICAL;
|
||||||
|
dim_line.color_pair = COLOR_DIM;
|
||||||
|
dim_line.is_bold = false;
|
||||||
|
dim_line.is_link = false;
|
||||||
|
dim_line.link_index = -1;
|
||||||
|
lines.push_back(dim_line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
RenderedLine bottom;
|
||||||
|
bottom.text = std::string(margin, ' ') + BoxChars::BOTTOM_LEFT +
|
||||||
|
std::string(img_text.length() + 2, BoxChars::HORIZONTAL[0]) +
|
||||||
|
BoxChars::BOTTOM_RIGHT;
|
||||||
|
bottom.color_pair = COLOR_DIM;
|
||||||
|
bottom.is_bold = false;
|
||||||
|
bottom.is_link = false;
|
||||||
|
bottom.link_index = -1;
|
||||||
|
lines.push_back(bottom);
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
TextRenderer::TextRenderer() : pImpl(std::make_unique<Impl>()) {
|
TextRenderer::TextRenderer() : pImpl(std::make_unique<Impl>()) {
|
||||||
|
|
@ -300,8 +524,52 @@ std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int sc
|
||||||
prefix = "> ";
|
prefix = "> ";
|
||||||
break;
|
break;
|
||||||
case ElementType::LIST_ITEM:
|
case ElementType::LIST_ITEM:
|
||||||
prefix = " • ";
|
{
|
||||||
|
// Different bullets for different nesting levels
|
||||||
|
const char* bullets[] = {"•", "◦", "▪", "▫"};
|
||||||
|
int indent = elem.nesting_level * 2;
|
||||||
|
int bullet_idx = elem.nesting_level % 4;
|
||||||
|
prefix = std::string(indent, ' ') + " " + bullets[bullet_idx] + " ";
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
case ElementType::ORDERED_LIST_ITEM:
|
||||||
|
{
|
||||||
|
// Numbered lists with proper indentation
|
||||||
|
int indent = elem.nesting_level * 2;
|
||||||
|
prefix = std::string(indent, ' ') + " " +
|
||||||
|
std::to_string(elem.list_number) + ". ";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ElementType::TABLE:
|
||||||
|
{
|
||||||
|
auto table_lines = pImpl->render_table(elem.table_data, content_width, margin);
|
||||||
|
lines.insert(lines.end(), table_lines.begin(), table_lines.end());
|
||||||
|
|
||||||
|
// Add empty line after table
|
||||||
|
RenderedLine empty;
|
||||||
|
empty.text = "";
|
||||||
|
empty.color_pair = COLOR_NORMAL;
|
||||||
|
empty.is_bold = false;
|
||||||
|
empty.is_link = false;
|
||||||
|
empty.link_index = -1;
|
||||||
|
lines.push_back(empty);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case ElementType::IMAGE:
|
||||||
|
{
|
||||||
|
auto img_lines = pImpl->render_image(elem.image_data, content_width, margin);
|
||||||
|
lines.insert(lines.end(), img_lines.begin(), img_lines.end());
|
||||||
|
|
||||||
|
// Add empty line after image
|
||||||
|
RenderedLine empty;
|
||||||
|
empty.text = "";
|
||||||
|
empty.color_pair = COLOR_NORMAL;
|
||||||
|
empty.is_bold = false;
|
||||||
|
empty.is_link = false;
|
||||||
|
empty.link_index = -1;
|
||||||
|
lines.push_back(empty);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
case ElementType::HORIZONTAL_RULE:
|
case ElementType::HORIZONTAL_RULE:
|
||||||
{
|
{
|
||||||
RenderedLine hr;
|
RenderedLine hr;
|
||||||
|
|
@ -314,6 +582,13 @@ std::vector<RenderedLine> TextRenderer::render(const ParsedDocument& doc, int sc
|
||||||
lines.push_back(hr);
|
lines.push_back(hr);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
case ElementType::HEADING4:
|
||||||
|
case ElementType::HEADING5:
|
||||||
|
case ElementType::HEADING6:
|
||||||
|
color = COLOR_HEADING3; // Use same color as H3 for H4-H6
|
||||||
|
bold = true;
|
||||||
|
prefix = std::string(elem.level, '#') + " ";
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue