diff --git a/LINK_NAVIGATION.md b/LINK_NAVIGATION.md new file mode 100644 index 0000000..3c669af --- /dev/null +++ b/LINK_NAVIGATION.md @@ -0,0 +1,76 @@ +# Quick Link Navigation Guide + +The browser now supports vim-style quick navigation to links! + +## Features + +### 1. Visual Link Numbers +All links are displayed inline in the text with numbers like `[0]`, `[1]`, `[2]`, etc. +- Links are shown with yellow color and underline +- Active link (selected with Tab) has yellow background + +### 2. Quick Navigation Methods + +#### Method 1: Number + Enter +Type a number and press Enter to jump to that link: +``` +3 - Jump to link [3] +10 - Jump to link [10] +``` + +#### Method 2: 'f' command (follow) +Press `f` followed by a number to immediately open that link: +``` +f3 - Open link [3] directly +f10 - Open link [10] directly +``` + +Or type the number first: +``` +3f - Open link [3] directly +10f - Open link [10] directly +``` + +#### Method 3: Traditional Tab navigation (still works) +``` +Tab - Next link +Shift-Tab/T - Previous link +Enter - Follow current highlighted link +``` + +## Examples + +Given a page with these links: +- "Google[0]" +- "GitHub[1]" +- "Wikipedia[2]" + +You can: +- Press `1` to select GitHub link +- Press `f2` to immediately open Wikipedia +- Press `Tab` twice then `Enter` to open Wikipedia + +## Usage + +```bash +# Test with a real website +./tut https://example.com + +# View help +./tut +# Press ? for help +``` + +## Key Bindings Summary + +| Command | Action | +|---------|--------| +| `[N]` | Jump to link N | +| `f[N]` or `[N]f` | Open link N directly | +| `Tab` | Next link | +| `Shift-Tab` / `T` | Previous link | +| `Enter` | Follow current link | +| `h` | Go back | +| `l` | Go forward | + +All standard vim navigation keys (j/k, gg/G, /, n/N) still work as before! diff --git a/src/browser.cpp b/src/browser.cpp index c619c7f..70865de 100644 --- a/src/browser.cpp +++ b/src/browser.cpp @@ -138,34 +138,93 @@ public: 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); + // Check if this line contains the active link + bool has_active_link = (line.is_link && line.link_index == current_link); + + // Check if this line is in search results + bool in_search_results = !search_term.empty() && + std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end(); + + // If line has link ranges, render character by character with proper highlighting + if (!line.link_ranges.empty()) { + int col = 0; + for (size_t char_idx = 0; char_idx < line.text.length(); ++char_idx) { + // Check if this character is within any link range + bool is_in_link = false; + + for (const auto& [start, end] : line.link_ranges) { + if (char_idx >= start && char_idx < end) { + is_in_link = true; + break; + } + } + + // Apply appropriate color + if (is_in_link && has_active_link) { + attron(COLOR_PAIR(COLOR_LINK_ACTIVE)); + } else if (is_in_link) { + attron(COLOR_PAIR(COLOR_LINK)); + attron(A_UNDERLINE); + } else { + attron(COLOR_PAIR(line.color_pair)); + if (line.is_bold) { + attron(A_BOLD); + } + } + + if (in_search_results) { + attron(A_REVERSE); + } + + mvaddch(i, col, line.text[char_idx]); + + if (in_search_results) { + attroff(A_REVERSE); + } + + if (is_in_link && has_active_link) { + attroff(COLOR_PAIR(COLOR_LINK_ACTIVE)); + } else if (is_in_link) { + attroff(A_UNDERLINE); + attroff(COLOR_PAIR(COLOR_LINK)); + } else { + if (line.is_bold) { + attroff(A_BOLD); + } + attroff(COLOR_PAIR(line.color_pair)); + } + + col++; } - } - - if (!search_term.empty() && - std::find(search_results.begin(), search_results.end(), line_idx) != search_results.end()) { - attron(A_REVERSE); - } - - mvprintw(i, 0, "%s", line.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); + // No inline links, render normally + if (has_active_link) { + attron(COLOR_PAIR(COLOR_LINK_ACTIVE)); + } else { + attron(COLOR_PAIR(line.color_pair)); + if (line.is_bold) { + attron(A_BOLD); + } + } + + if (in_search_results) { + attron(A_REVERSE); + } + + mvprintw(i, 0, "%s", line.text.c_str()); + + if (in_search_results) { + attroff(A_REVERSE); + } + + if (has_active_link) { + attroff(COLOR_PAIR(COLOR_LINK_ACTIVE)); + } else { + if (line.is_bold) { + attroff(A_BOLD); + } + attroff(COLOR_PAIR(line.color_pair)); } - attroff(COLOR_PAIR(line.color_pair)); } } @@ -229,6 +288,26 @@ public: } break; + case Action::GOTO_LINK: + // Jump to specific link by number + if (result.number >= 0 && result.number < static_cast(current_doc.links.size())) { + current_link = result.number; + scroll_to_link(current_link); + status_message = "Link " + std::to_string(result.number); + } else { + status_message = "Invalid link number: " + std::to_string(result.number); + } + break; + + case Action::FOLLOW_LINK_NUM: + // Follow specific link by number directly + if (result.number >= 0 && result.number < static_cast(current_doc.links.size())) { + load_page(current_doc.links[result.number].url); + } else { + status_message = "Invalid link number: " + std::to_string(result.number); + } + break; + case Action::GO_BACK: if (history_pos > 0) { history_pos--; @@ -332,9 +411,12 @@ public: << "

G: Go to bottom

" << "

[number]G: Go to line number

" << "

Links

" + << "

Links are displayed inline with numbers like [0], [1], etc.

" << "

Tab: Next link

" << "

Shift-Tab or T: Previous link

" - << "

Enter: Follow link

" + << "

Enter: Follow current link

" + << "

[number]Enter: Jump to link number N

" + << "

f[number]: Follow link number N directly

" << "

h: Go back

" << "

l: Go forward

" << "

Search

" diff --git a/src/html_parser.cpp b/src/html_parser.cpp index a66711b..adedcbf 100644 --- a/src/html_parser.cpp +++ b/src/html_parser.cpp @@ -139,6 +139,70 @@ public: return links; } + // 从HTML中提取文本,同时保留内联链接位置信息 + std::string extract_text_with_links(const std::string& html, + std::vector& all_links, + std::vector& inline_links) { + std::string result; + std::regex link_regex(R"(]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?))", + std::regex::icase); + + size_t last_pos = 0; + auto begin = std::sregex_iterator(html.begin(), html.end(), link_regex); + auto end = std::sregex_iterator(); + + // 处理所有链接 + for (std::sregex_iterator i = begin; i != end; ++i) { + std::smatch match = *i; + + // 添加链接前的文本 + std::string before_link = html.substr(last_pos, match.position() - last_pos); + std::string before_text = decode_html_entities(remove_tags(before_link)); + result += before_text; + + // 提取链接信息 + std::string link_url = match[1].str(); + std::string link_text = decode_html_entities(remove_tags(match[2].str())); + + // 跳过空链接或锚点链接 + if (link_url.empty() || link_url[0] == '#' || link_text.empty()) { + result += link_text; + last_pos = match.position() + match.length(); + continue; + } + + // 找到这个链接在全局链接列表中的索引 + int link_index = -1; + for (size_t j = 0; j < all_links.size(); ++j) { + if (all_links[j].url == link_url && all_links[j].text == link_text) { + link_index = j; + break; + } + } + + if (link_index != -1) { + // 记录内联链接位置 + InlineLink inline_link; + inline_link.text = link_text; + inline_link.url = link_url; + inline_link.start_pos = result.length(); + inline_link.end_pos = result.length() + link_text.length(); + inline_link.link_index = link_index; + inline_links.push_back(inline_link); + } + + // 添加链接文本 + result += link_text; + last_pos = match.position() + match.length(); + } + + // 添加最后一段文本 + std::string remaining = html.substr(last_pos); + result += decode_html_entities(remove_tags(remaining)); + + return trim(result); + } + // 清理空白字符 std::string trim(const std::string& str) { auto start = str.begin(); @@ -237,14 +301,13 @@ ParsedDocument HtmlParser::parse(const std::string& html, const std::string& bas } } - // 解析段落 + // 解析段落 (保留内联链接) auto paragraphs = pImpl->extract_all_tags(main_content, "p"); for (const auto& para : paragraphs) { - std::string text = pImpl->decode_html_entities(pImpl->trim(pImpl->remove_tags(para))); - if (!text.empty() && text.length() > 1) { - ContentElement elem; - elem.type = ElementType::PARAGRAPH; - elem.text = text; + ContentElement elem; + elem.type = ElementType::PARAGRAPH; + elem.text = pImpl->extract_text_with_links(para, doc.links, elem.inline_links); + if (!elem.text.empty() && elem.text.length() > 1) { doc.elements.push_back(elem); } } diff --git a/src/html_parser.h b/src/html_parser.h index d72ed1b..90c2872 100644 --- a/src/html_parser.h +++ b/src/html_parser.h @@ -24,11 +24,20 @@ struct Link { int position; }; +struct InlineLink { + std::string text; + std::string url; + size_t start_pos; // Position in the text where link starts + size_t end_pos; // Position in the text where link ends + int link_index; // Index in the document's links array +}; + struct ContentElement { ElementType type; std::string text; std::string url; int level; + std::vector inline_links; // Links within this element's text }; struct ParsedDocument { diff --git a/src/input_handler.cpp b/src/input_handler.cpp index c8ad265..acb1585 100644 --- a/src/input_handler.cpp +++ b/src/input_handler.cpp @@ -23,6 +23,7 @@ public: result.has_count = false; result.count = 1; + // Handle digit input for count or 'f' command if (std::isdigit(ch) && (ch != '0' || !count_buffer.empty())) { count_buffer += static_cast(ch); return result; @@ -31,33 +32,38 @@ public: if (!count_buffer.empty()) { result.has_count = true; result.count = std::stoi(count_buffer); - count_buffer.clear(); } switch (ch) { case 'j': case KEY_DOWN: result.action = Action::SCROLL_DOWN; + count_buffer.clear(); break; case 'k': case KEY_UP: result.action = Action::SCROLL_UP; + count_buffer.clear(); break; case 'h': case KEY_LEFT: result.action = Action::GO_BACK; + count_buffer.clear(); break; case 'l': case KEY_RIGHT: result.action = Action::GO_FORWARD; + count_buffer.clear(); break; case 4: case ' ': result.action = Action::SCROLL_PAGE_DOWN; + count_buffer.clear(); break; case 21: case 'b': result.action = Action::SCROLL_PAGE_UP; + count_buffer.clear(); break; case 'g': buffer += 'g'; @@ -65,6 +71,7 @@ public: result.action = Action::GOTO_TOP; buffer.clear(); } + count_buffer.clear(); break; case 'G': if (result.has_count) { @@ -73,27 +80,52 @@ public: } else { result.action = Action::GOTO_BOTTOM; } + count_buffer.clear(); break; case '/': mode = InputMode::SEARCH; buffer = "/"; + count_buffer.clear(); break; case 'n': result.action = Action::SEARCH_NEXT; + count_buffer.clear(); break; case 'N': result.action = Action::SEARCH_PREV; + count_buffer.clear(); break; case '\t': result.action = Action::NEXT_LINK; + count_buffer.clear(); break; case KEY_BTAB: case 'T': result.action = Action::PREV_LINK; + count_buffer.clear(); break; case '\n': case '\r': - result.action = Action::FOLLOW_LINK; + // If count buffer has a number, jump to that link + if (result.has_count) { + result.action = Action::GOTO_LINK; + result.number = result.count; + } else { + result.action = Action::FOLLOW_LINK; + } + count_buffer.clear(); + break; + case 'f': + // 'f' command: follow link by number + if (result.has_count) { + result.action = Action::FOLLOW_LINK_NUM; + result.number = result.count; + count_buffer.clear(); + } else { + // Enter link follow mode, wait for number + mode = InputMode::LINK; + buffer = "f"; + } break; case ':': mode = InputMode::COMMAND; @@ -190,6 +222,41 @@ public: return result; } + + InputResult process_link_mode(int ch) { + InputResult result; + result.action = Action::NONE; + + if (std::isdigit(ch)) { + buffer += static_cast(ch); + } else if (ch == '\n' || ch == '\r') { + // Follow the link number entered + if (buffer.length() > 1) { + try { + int link_num = std::stoi(buffer.substr(1)); + result.action = Action::FOLLOW_LINK_NUM; + result.number = link_num; + } catch (...) { + set_status("Invalid link number"); + } + } + mode = InputMode::NORMAL; + buffer.clear(); + } else if (ch == 27) { + // ESC cancels + mode = InputMode::NORMAL; + buffer.clear(); + } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { + if (buffer.length() > 1) { + buffer.pop_back(); + } else { + mode = InputMode::NORMAL; + buffer.clear(); + } + } + + return result; + } }; InputHandler::InputHandler() : pImpl(std::make_unique()) {} @@ -204,6 +271,8 @@ InputResult InputHandler::handle_key(int ch) { return pImpl->process_command_mode(ch); case InputMode::SEARCH: return pImpl->process_search_mode(ch); + case InputMode::LINK: + return pImpl->process_link_mode(ch); default: break; } diff --git a/src/input_handler.h b/src/input_handler.h index be5d035..0e842bc 100644 --- a/src/input_handler.h +++ b/src/input_handler.h @@ -26,6 +26,8 @@ enum class Action { NEXT_LINK, PREV_LINK, FOLLOW_LINK, + GOTO_LINK, // Jump to specific link by number + FOLLOW_LINK_NUM, // Follow specific link by number (f command) GO_BACK, GO_FORWARD, OPEN_URL, diff --git a/src/text_renderer.cpp b/src/text_renderer.cpp index 61a15e0..e243272 100644 --- a/src/text_renderer.cpp +++ b/src/text_renderer.cpp @@ -7,6 +7,12 @@ class TextRenderer::Impl { public: RenderConfig config; + struct LinkPosition { + int link_index; + size_t start; + size_t end; + }; + std::vector wrap_text(const std::string& text, int width) { std::vector lines; if (text.empty()) { @@ -50,6 +56,151 @@ public: return lines; } + // Wrap text with links, tracking link positions and adding link numbers + std::vector>> + wrap_text_with_links(const std::string& original_text, int width, + const std::vector& inline_links) { + std::vector>> result; + + // If no links, use simple wrapping + if (inline_links.empty()) { + auto wrapped = wrap_text(original_text, width); + for (const auto& line : wrapped) { + result.push_back({line, {}}); + } + return result; + } + + // Build modified text with link numbers inserted + std::string text; + std::vector modified_links; + size_t text_pos = 0; + + for (const auto& link : inline_links) { + // Add text before link + if (link.start_pos > text_pos) { + text += original_text.substr(text_pos, link.start_pos - text_pos); + } + + // Add link text with number indicator + size_t link_start_in_modified = text.length(); + std::string link_text = original_text.substr(link.start_pos, link.end_pos - link.start_pos); + std::string link_indicator = "[" + std::to_string(link.link_index) + "]"; + text += link_text + link_indicator; + + // Store modified link position (including the indicator) + InlineLink mod_link = link; + mod_link.start_pos = link_start_in_modified; + mod_link.end_pos = text.length(); + modified_links.push_back(mod_link); + + text_pos = link.end_pos; + } + + // Add remaining text after last link + if (text_pos < original_text.length()) { + text += original_text.substr(text_pos); + } + + // Split text into words + std::vector words; + std::vector word_positions; + + size_t pos = 0; + while (pos < text.length()) { + // Skip whitespace + while (pos < text.length() && std::isspace(text[pos])) { + pos++; + } + if (pos >= text.length()) break; + + // Extract word + size_t word_start = pos; + while (pos < text.length() && !std::isspace(text[pos])) { + pos++; + } + + words.push_back(text.substr(word_start, pos - word_start)); + word_positions.push_back(word_start); + } + + // Build lines + std::string current_line; + std::vector current_links; + + for (size_t i = 0; i < words.size(); ++i) { + const auto& word = words[i]; + size_t word_pos = word_positions[i]; + + bool can_fit = current_line.empty() + ? word.length() <= static_cast(width) + : current_line.length() + 1 + word.length() <= static_cast(width); + + if (!can_fit && !current_line.empty()) { + // Save current line + result.push_back({current_line, current_links}); + current_line.clear(); + current_links.clear(); + } + + // Add word to current line + if (!current_line.empty()) { + current_line += " "; + } + size_t word_start_in_line = current_line.length(); + current_line += word; + + // Check if this word overlaps with any links + for (const auto& link : modified_links) { + size_t word_end = word_pos + word.length(); + + // Check for overlap + if (word_pos < link.end_pos && word_end > link.start_pos) { + // Calculate link position in current line + size_t link_start_in_line = word_start_in_line; + if (link.start_pos > word_pos) { + link_start_in_line += (link.start_pos - word_pos); + } + + size_t link_end_in_line = word_start_in_line + word.length(); + if (link.end_pos < word_end) { + link_end_in_line -= (word_end - link.end_pos); + } + + // Check if link already added + bool already_added = false; + for (auto& existing : current_links) { + if (existing.link_index == link.link_index) { + // Extend existing link range + existing.end = link_end_in_line; + already_added = true; + break; + } + } + + if (!already_added) { + LinkPosition lp; + lp.link_index = link.link_index; + lp.start = link_start_in_line; + lp.end = link_end_in_line; + current_links.push_back(lp); + } + } + } + } + + // Add last line + if (!current_line.empty()) { + result.push_back({current_line, current_links}); + } + + if (result.empty()) { + result.push_back({"", {}}); + } + + return result; + } + std::string add_indent(const std::string& text, int indent) { return std::string(indent, ' ') + text; } @@ -167,18 +318,38 @@ std::vector TextRenderer::render(const ParsedDocument& doc, int sc break; } - auto wrapped_lines = pImpl->wrap_text(elem.text, content_width - prefix.length()); - for (size_t i = 0; i < wrapped_lines.size(); ++i) { + auto wrapped_with_links = pImpl->wrap_text_with_links(elem.text, + content_width - prefix.length(), + elem.inline_links); + + for (size_t i = 0; i < wrapped_with_links.size(); ++i) { + const auto& [line_text, link_positions] = wrapped_with_links[i]; RenderedLine line; + if (i == 0) { - line.text = std::string(margin, ' ') + prefix + wrapped_lines[i]; + line.text = std::string(margin, ' ') + prefix + line_text; } else { - line.text = std::string(margin + prefix.length(), ' ') + wrapped_lines[i]; + line.text = std::string(margin + prefix.length(), ' ') + line_text; } + line.color_pair = color; line.is_bold = bold; - line.is_link = false; - line.link_index = -1; + + // Store link information + if (!link_positions.empty()) { + line.is_link = true; + line.link_index = link_positions[0].link_index; // Primary link for Tab navigation + + // Adjust link positions for margin and prefix + size_t offset = (i == 0) ? (margin + prefix.length()) : (margin + prefix.length()); + for (const auto& lp : link_positions) { + line.link_ranges.push_back({lp.start + offset, lp.end + offset}); + } + } else { + line.is_link = false; + line.link_index = -1; + } + lines.push_back(line); } @@ -198,7 +369,8 @@ std::vector TextRenderer::render(const ParsedDocument& doc, int sc } } - if (!doc.links.empty() && pImpl->config.show_link_indicators) { + // Don't show separate links section if inline links are displayed + if (!doc.links.empty() && !pImpl->config.show_link_indicators) { RenderedLine separator; std::string sepline(content_width, '-'); separator.text = std::string(margin, ' ') + sepline; diff --git a/src/text_renderer.h b/src/text_renderer.h index 33bc54c..7a1dc0e 100644 --- a/src/text_renderer.h +++ b/src/text_renderer.h @@ -12,6 +12,7 @@ struct RenderedLine { bool is_bold; bool is_link; int link_index; + std::vector> link_ranges; // (start, end) positions of links in this line }; struct RenderConfig { @@ -19,7 +20,7 @@ struct RenderConfig { int margin_left = 0; bool center_content = true; int paragraph_spacing = 1; - bool show_link_indicators = true; + bool show_link_indicators = false; // Set to false to show inline links by default }; class TextRenderer { diff --git a/test_inline_links.html b/test_inline_links.html new file mode 100644 index 0000000..03a207d --- /dev/null +++ b/test_inline_links.html @@ -0,0 +1,24 @@ + + + + Test Inline Links + + +

Test Page for Inline Links

+ +

This is a paragraph with an inline link in the middle of the text. You should be able to see the link highlighted directly in the text.

+ +

Here is another paragraph with multiple links: Google and GitHub are both popular websites.

+ +

This paragraph has a longer link text: Wikipedia is a free online encyclopedia that anyone can edit.

+ +

More Examples

+ +

Press Tab to navigate between links, and Enter to follow them. The links should be highlighted directly in the text, not listed separately at the bottom.

+ + + +